Home Java Spring WebClient Filters: Logging, Retry, Authentication, and Custom Filters
Advanced 6 min · July 14, 2026

Spring WebClient Filters: Logging, Retry, Authentication, and Custom Filters

Master WebClient filters for logging, retries, and auth.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 20-25 min read
  • Java 17+
  • Spring Boot 3.x
  • Basic understanding of reactive programming with Project Reactor
  • Familiarity with WebClient basics
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • WebClient filters allow you to intercept and modify requests/responses in a reactive pipeline.
  • Use ExchangeFilterFunction to add logging, retry logic, authentication headers, or custom behavior.
  • Filters are applied in order; order matters for logging and security.
  • Avoid blocking calls inside filters; use reactive types (Mono/Flux).
  • Production debugging: use dedicated logging filters, not generic ones.
✦ Definition~90s read
What is Spring WebClient Filters?

WebClient filters are ExchangeFilterFunction implementations that intercept and modify HTTP requests and responses in a reactive pipeline, enabling cross-cutting concerns like logging, retry, and authentication.

Think of WebClient filters as airport security checkpoints for your HTTP requests.
Plain-English First

Think of WebClient filters as airport security checkpoints for your HTTP requests. Each filter can inspect, modify, or even stop a request before it leaves (or when a response comes back). You can add a stamp (authentication), check the luggage (logging), or send the request through again if it gets lost (retry).

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

If you're still using RestTemplate in new Spring Boot projects, stop. WebClient is the future—non-blocking, reactive, and far more flexible. But with great power comes great complexity. I've seen teams struggle with WebClient, especially when they need cross-cutting concerns like logging, retries, or authentication. That's where filters come in.

Filters in WebClient are like servlet filters or Spring MVC interceptors, but reactive. They let you intercept every request and response, modify headers, log payloads, handle errors, and even retry failed calls. I've built payment processing systems where a missing filter caused silent failures that took weeks to track down. In this article, I'll show you how to use WebClient filters effectively, share production war stories, and point out the traps that the official docs gloss over.

We'll cover built-in filters for logging and retry, custom authentication filters, and how to chain them correctly. By the end, you'll be able to build robust, observable HTTP clients that survive production traffic.

What Are WebClient Filters?

If you've used Spring MVC's HandlerInterceptor or servlet filters, you already understand the concept: intercept requests and responses to add cross-cutting behavior. WebClient filters are the reactive equivalent. They implement ExchangeFilterFunction, which takes a ClientRequest and the next filter in the chain, and returns Mono<ClientResponse>.

``java ExchangeFilterFunction logFilter = (request, next) -> { log.info("Request: {} {}", request.method(), request.url()); return next.exchange(request); }; ``

This filter logs every outgoing request. But don't stop there. Filters can modify requests (add headers, change URI), modify responses (log status, transform body), or even short-circuit by returning a response without calling next.exchange(). I've used that last trick for mocking in tests.

Filters are applied in the order you add them to the WebClient.Builder. This matters. If you put a retry filter before an auth filter, the retry will include the auth setup, which is usually what you want. But if you put a logging filter that modifies headers after the auth filter, you might accidentally overwrite auth headers. Ordering is key.

One more thing: filters are per-WebClient instance. If you need different behavior for different clients, create separate WebClient beans with their own filter chains. Don't try to use one-size-fits-all filters with conditional logic—it becomes a maintenance nightmare.

WebClientFilterExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class WebClientFilterExample {
    private static final Logger log = LoggerFactory.getLogger(WebClientFilterExample.class);

    public static void main(String[] args) {
        ExchangeFilterFunction logFilter = (request, next) -> {
            log.info("Request: {} {}", request.method(), request.url());
            return next.exchange(request);
        };

        WebClient client = WebClient.builder()
                .filter(logFilter)
                .baseUrl("https://api.example.com")
                .build();

        String response = client.get()
                .uri("/data")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        System.out.println(response);
    }
}
Output
Request: GET https://api.example.com/data
[response body]
🔥Filters vs. Exchange Strategies
📊 Production Insight
I once saw a team put a filter that logged the entire request body at DEBUG level. In production, the log files grew by 10GB per hour. Always use conditional logging (e.g., only log headers, not body) or make it configurable.
🎯 Key Takeaway
WebClient filters let you intercept and modify requests/responses in a reactive pipeline. Order matters: place auth filters before retry filters.

Logging Filters: What the Official Docs Won't Tell You

Spring Boot's spring.webclient.logging property? There isn't one. The docs tell you to enable DEBUG logging for org.springframework.web.reactive.function.client.ExchangeFunctions, but that logs everything—including the full request/response bodies, which can be huge. I've seen production incidents where enabling this caused OutOfMemoryErrors because of large payloads.

Instead, write a custom logging filter that gives you control. Here's my battle-tested version:

``java @Slf4j @Component public class LoggingFilter implements ExchangeFilterFunction { @Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { log.info("Outgoing request: {} {}", request.method(), request.url()); if (log.isDebugEnabled()) { request.headers().forEach((name, values) -> values.forEach(value -> log.debug("Header: {}={}", name, value))); } return next.exchange(request).flatMap(response -> { log.info("Response status: {}", response.statusCode()); return Mono.just(response); }); } } ``

Notice I don't log the body by default. If you need body logging, use a ClientResponse wrapper that reads the body and then re-creates it (because the body can be read only once in reactive streams). Here's a helper:

``java private Mono<ClientResponse> logBody(ClientResponse response) { return response.bodyToMono(String.class) .flatMap(body -> { log.debug("Response body: {}", body); return Mono.just(ClientResponse.from(response).body(body).build()); }); } ``

But beware: this buffers the entire body in memory. For large payloads, use a streaming approach or log only the first N bytes.

LoggingFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class LoggingFilter implements ExchangeFilterFunction {

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        log.info("Outgoing request: {} {}", request.method(), request.url());
        if (log.isDebugEnabled()) {
            request.headers().forEach((name, values) -> 
                values.forEach(value -> log.debug("Header: {}={}", name, value)));
        }
        return next.exchange(request).flatMap(response -> {
            log.info("Response status: {}", response.statusCode());
            return Mono.just(response);
        });
    }
}
Output
Outgoing request: GET https://api.example.com/data
Response status: 200 OK
⚠ Don't Log Request Bodies in Production
📊 Production Insight
At a healthcare SaaS company, we had a filter that logged full request bodies. A developer accidentally committed with DEBUG enabled, and we exposed PHI in logs. We now use a whitelist approach: only log headers, and only log bodies for specific endpoints via configuration.
🎯 Key Takeaway
Avoid Spring's default logging; write a custom filter that logs only what you need. Never log bodies in production without masking sensitive data.

Retry Filters: Don't Do This at Home (Unless You Know the Pitfalls)

Retrying failed requests sounds simple, but get it wrong and you'll amplify outages or cause duplicate side effects. Spring WebClient doesn't have a built-in retry filter; you need to use Project Reactor's Retry spec.

Here's the right way: create a filter that wraps the exchange with retry logic. But—and this is critical—only retry on idempotent HTTP methods (GET, PUT, DELETE) and on specific status codes (5xx, not 4xx). I've seen teams retry on 429 (Too Many Requests) without backoff, making the problem worse.

```java @Slf4j @Component public class RetryFilter implements ExchangeFilterFunction {

private final Retry retrySpec = Retry.backoff(3, Duration.ofSeconds(1)) .maxBackoff(Duration.ofSeconds(10)) .jitter(0.5) .filter(this::isRetryableException);

@Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { return next.exchange(request) .flatMap(response -> { if (response.statusCode().is5xxServerError()) { return Mono.error(new RetryableException(response.statusCode())); } return Mono.just(response); }) .retryWhen(retrySpec) .doOnError(e -> log.error("Request failed after retries: {} {}", request.method(), request.url())); }

private boolean isRetryableException(Throwable t) { if (t instanceof WebClientResponseException) { HttpStatus status = ((WebClientResponseException) t).getStatusCode(); return status.is5xxServerError() || status == HttpStatus.TOO_MANY_REQUESTS; } return t instanceof IOException; // network errors }

private static class RetryableException extends RuntimeException { public RetryableException(HttpStatus status) { super("Retryable status: " + status); } } } ```

Notice I check for 5xx and TOO_MANY_REQUESTS. Never retry on 4xx (client errors) unless you know the API returns 4xx for transient errors (some do, but it's rare). Also, always use exponential backoff with jitter to avoid thundering herd.

RetryFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

import java.io.IOException;
import java.time.Duration;

@Slf4j
@Component
public class RetryFilter implements ExchangeFilterFunction {

    private final Retry retrySpec = Retry.backoff(3, Duration.ofSeconds(1))
            .maxBackoff(Duration.ofSeconds(10))
            .jitter(0.5)
            .filter(this::isRetryableException);

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        return next.exchange(request)
                .flatMap(response -> {
                    if (response.statusCode().is5xxServerError()) {
                        return Mono.error(new RetryableException(response.statusCode()));
                    }
                    return Mono.just(response);
                })
                .retryWhen(retrySpec)
                .doOnError(e -> log.error("Request failed after retries: {} {}", request.method(), request.url()));
    }

    private boolean isRetryableException(Throwable t) {
        if (t instanceof WebClientResponseException) {
            HttpStatus status = ((WebClientResponseException) t).getStatusCode();
            return status.is5xxServerError() || status == HttpStatus.TOO_MANY_REQUESTS;
        }
        return t instanceof IOException;
    }

    private static class RetryableException extends RuntimeException {
        public RetryableException(HttpStatus status) {
            super("Retryable status: " + status);
        }
    }
}
⚠ Idempotency Is Your Responsibility
📊 Production Insight
At an e-commerce company, we had a retry filter that retried all 5xx errors. A downstream inventory service had a bug that returned 503 for out-of-stock items. Our retries amplified the load, causing a cascading failure. We added a circuit breaker after that.
🎯 Key Takeaway
Use Retry.backoff with jitter, only retry on 5xx and network errors, and never retry non-idempotent requests without an idempotency key.

Authentication Filters: The Right Way to Add Tokens

I've seen developers add authentication tokens in service code, like this:

``java webClient.get() .headers(h -> h.setBearerAuth(token)) .retrieve() .bodyToMono(...) ``

This works, but it's error-prone. If you forget to add the header in one place, you get a 401. Instead, use a filter that injects the token automatically. This ensures every request from that WebClient has authentication.

Here's a filter that fetches an OAuth2 token from a reactive client credentials provider:

```java @Slf4j @Component @RequiredArgsConstructor public class AuthFilter implements ExchangeFilterFunction {

private final ReactiveOAuth2AuthorizedClientManager authorizedClientManager; private final ClientRegistrationRepository clientRegistrationRepository;

@Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { return Mono.justOrEmpty(clientRegistrationRepository.findByRegistrationId("my-api")) .flatMap(registration -> authorizedClientManager.authorize( OAuth2AuthorizeExchange.with(clientRegistrationRepository) .principal(registration) .build() )) .map(authorized -> authorized.getAccessToken().getTokenValue()) .defaultIfEmpty("") .flatMap(token -> { ClientRequest newRequest = ClientRequest.from(request) .headers(h -> h.setBearerAuth(token)) .build(); return next.exchange(newRequest); }); } } ```

If you're using a simple static token (API key), it's even simpler:

``java @Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { return Mono.fromCallable(() -> ClientRequest.from(request) .headers(h -> h.setBearerAuth(apiKey)) .build()) .flatMap(next::exchange); } ``

But be careful: if the token is null or empty, you'll send requests without auth. Always handle that case, either by failing fast or using a default value that will cause a clear error.

AuthFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client.OAuth2AuthorizeExchange;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Mono;

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthFilter implements ExchangeFilterFunction {

    private final ReactiveOAuth2AuthorizedClientManager authorizedClientManager;
    private final ClientRegistrationRepository clientRegistrationRepository;

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        return Mono.justOrEmpty(clientRegistrationRepository.findByRegistrationId("my-api"))
                .flatMap(registration -> authorizedClientManager.authorize(
                        OAuth2AuthorizeExchange.with(clientRegistrationRepository)
                                .principal(registration)
                                .build()
                ))
                .map(authorized -> authorized.getAccessToken().getTokenValue())
                .defaultIfEmpty("")
                .flatMap(token -> {
                    ClientRequest newRequest = ClientRequest.from(request)
                            .headers(h -> h.setBearerAuth(token))
                            .build();
                    return next.exchange(newRequest);
                });
    }
}
💡Token Refresh Is Handled Automatically
📊 Production Insight
I worked on a microservices platform where each service had its own WebClient for inter-service calls. We used a shared auth filter that injected a JWT from the security context. When the context was missing (e.g., in a scheduled task), the filter threw a NullPointerException. We added a fallback to a service account token.
🎯 Key Takeaway
Always use a filter for authentication to ensure every request gets the token. Use Spring Security's OAuth2 support for automatic token management.

Custom Filters: Real-World Patterns

Beyond logging, retry, and auth, you'll need custom filters for things like:

  • Correlation IDs: Inject a unique ID into every request for tracing.
  • Metrics: Record request duration, status codes, etc.
  • Request/Response transformation: e.g., adding a common query parameter, or decrypting a response body.
  • Circuit breaking: Wrap the exchange with a reactive circuit breaker like Resilience4j.

Here's a correlation ID filter that propagates a trace ID from the current context:

```java @Slf4j @Component public class CorrelationIdFilter implements ExchangeFilterFunction {

private static final String CORRELATION_ID_HEADER = "X-Correlation-Id";

@Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { return Mono.deferContextual(ctx -> { String correlationId = ctx.getOrDefault("correlationId", UUID.randomUUID().toString()); ClientRequest newRequest = ClientRequest.from(request) .headers(h -> h.set(CORRELATION_ID_HEADER, correlationId)) .build(); return next.exchange(newRequest); }); } } ```

```java @Slf4j @Component @RequiredArgsConstructor public class MetricsFilter implements ExchangeFilterFunction {

private final MeterRegistry meterRegistry;

@Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { TimedSample sample = Timer.start(meterRegistry); return next.exchange(request) .doOnNext(response -> sample.stop(Timer.builder("http.client.requests") .tag("method", request.method().name()) .tag("uri", request.url().toString()) .tag("status", String.valueOf(response.statusCode().value())) .register(meterRegistry))); } } ```

Custom filters are where WebClient shines. You can build a rich, observable HTTP client without cluttering your business logic.

CorrelationIdFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;

import java.util.UUID;

@Slf4j
@Component
public class CorrelationIdFilter implements ExchangeFilterFunction {

    private static final String CORRELATION_ID_HEADER = "X-Correlation-Id";

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        return Mono.deferContextual(ctx -> {
            String correlationId = ctx.getOrDefault("correlationId", UUID.randomUUID().toString());
            ClientRequest newRequest = ClientRequest.from(request)
                    .headers(h -> h.set(CORRELATION_ID_HEADER, correlationId))
                    .build();
            return next.exchange(newRequest);
        });
    }
}
💡Reactive Context Propagation
📊 Production Insight
In a real-time analytics pipeline, we used a custom filter to add a timestamp header for latency tracking. The downstream service used it to calculate network delays. Without the filter, we'd have to add the header in every call.
🎯 Key Takeaway
Custom filters for correlation IDs, metrics, and circuit breaking keep your code clean and make your HTTP client production-ready.

What the Official Docs Won't Tell You

The Spring WebClient reference documentation covers the basics of ExchangeFilterFunction, but it glosses over several critical details that will bite you in production.

1. Filters are applied in order, but the order of filter() calls matters. If you do builder.filter(a).filter(b), then a runs first (outermost), then b. This is intuitive, but if you later refactor and change the order, you might break authentication or logging expectations.

2. You can't read the request body twice. If a filter reads the body (e.g., for logging), it consumes the stream. Subsequent filters or the actual exchange will get an empty body. You must buffer the body and re-create the request, or use a ClientRequest wrapper that caches the body.

**3. The ExchangeFilterFunction interface has two methods: filter and defaultIfEmpty? No, it's just filter. But there's also ExchangeFilterFunction.ofRequestProcessor and ofResponseProcessor for convenience. These are higher-order functions that create filters for you. Use them for simple cases, but for complex logic (like retry), implement the interface directly.

4. Exception handling in filters is tricky. If a filter throws an exception, it will propagate up the reactive chain. But if you use doOnError or onErrorResume inside the filter, you might swallow exceptions that should propagate. Be explicit about which exceptions you handle and which you rethrow.

5. WebClient filters are not applied to exchangeToMono or exchangeToFlux? Actually, they are. Filters wrap the entire exchange, so they apply to all exchange methods. But if you use retrieve() and then bodyToMono, the filter still runs.

6. Performance impact. Each filter adds overhead. In high-throughput systems, excessive logging or body buffering can degrade performance. Profile your filters in production-like conditions.

7. Testing filters requires careful setup. Use WebClient.builder().exchangeFunction(...) to mock the exchange, or use WireMock to test the full HTTP call. I'll show you how in the next section.

FilterOrderTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

public class FilterOrderTest {

    @Test
    public void testFilterOrder() {
        List<String> order = new ArrayList<>();

        ExchangeFilterFunction filterA = (request, next) -> {
            order.add("A");
            return next.exchange(request);
        };
        ExchangeFilterFunction filterB = (request, next) -> {
            order.add("B");
            return next.exchange(request);
        };

        WebClient client = WebClient.builder()
                .filter(filterA)
                .filter(filterB)
                .exchangeFunction(req -> Mono.just(ClientResponse.create(200).build()))
                .build();

        client.get().uri("/test").exchange().block();

        assertThat(order).containsExactly("A", "B");
    }
}
⚠ Body Buffering Can Cause OOM
📊 Production Insight
I once spent a day debugging why a filter that added a header wasn't working. Turns out, another filter was overwriting headers by calling headers.set() instead of headers.add(). The order of filters made the overwrite happen after my filter. We now use headers.add() in filters to avoid overwriting.
🎯 Key Takeaway
The official docs miss critical details: order matters, body can't be read twice, exception handling is nuanced, and performance overhead is real.

Testing WebClient Filters with WireMock

Testing filters in isolation is possible, but integration testing with a real HTTP server is more reliable. WireMock is my go-to. It lets you stub HTTP endpoints and verify requests.

``groovy testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner' ``

Then, create a test that uses WireMock to simulate the external service:

```java @SpringBootTest @AutoConfigureWireMock(port = 0) class WebClientFilterTest {

@Autowired private WebClient.Builder webClientBuilder;

@Value("${wiremock.server.port}") private int wiremockPort;

@Test void authFilterShouldAddBearerToken() { stubFor(get(urlEqualTo("/api/data")) .withHeader("Authorization", equalTo("Bearer test-token")) .willReturn(aResponse().withStatus(200).withBody("OK")));

WebClient client = webClientBuilder .baseUrl("http://localhost:" + wiremockPort) .build();

String response = client.get() .uri("/api/data") .retrieve() .bodyToMono(String.class) .block();

assertThat(response).isEqualTo("OK"); } } ```

This test ensures the auth filter adds the correct header. You can also test retry behavior by returning 503 then 200:

```java @Test void retryFilterShouldRetryOnServerError() { stubFor(get(urlEqualTo("/api/data")) .willReturn(aResponse().withStatus(503)) .inScenario("Retry scenario") .whenScenarioStateIs(Scenario.STARTED) .willSetStateTo("Retried"));

stubFor(get(urlEqualTo("/api/data")) .inScenario("Retry scenario") .whenScenarioStateIs("Retried") .willReturn(aResponse().withStatus(200).withBody("OK")));

// ... same client setup } ```

WireMock's scenario feature is perfect for testing retry logic. Don't rely on unit tests that mock ExchangeFunction—they miss real HTTP behavior.

WebClientFilterTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.web.reactive.function.client.WebClient;

import static com.github.tomakehurst.wiremock.client.WireMock.*;

@SpringBootTest
@WireMockTest(httpPort = 8080)
public class WebClientFilterTest {

    @Autowired
    private WebClient.Builder webClientBuilder;

    @Test
    void authFilterShouldAddBearerToken() {
        stubFor(get(urlEqualTo("/api/data"))
                .withHeader("Authorization", equalTo("Bearer test-token"))
                .willReturn(aResponse().withStatus(200).withBody("OK")));

        WebClient client = webClientBuilder
                .baseUrl("http://localhost:8080")
                .build();

        String response = client.get()
                .uri("/api/data")
                .retrieve()
                .bodyToMono(String.class)
                .block();

        assertThat(response).isEqualTo("OK");
    }
}
💡Use `@AutoConfigureWireMock` with Random Port
📊 Production Insight
I once pushed a filter that accidentally removed the Content-Type header. WireMock caught it because the stub expected the header. Without that test, the bug would have reached production.
🎯 Key Takeaway
Test filters with WireMock to verify real HTTP behavior, including header injection, retries, and error handling.
● Production incidentPOST-MORTEMseverity: high

The Silent 401: How a Missing Auth Filter Cost a Fintech $10k

Symptom
Users reported that payments were failing with no clear error. Backend logs showed no outgoing HTTP calls for the payment service.
Assumption
The developer assumed the authentication token was being added by a previous interceptor in the service layer.
Root cause
The WebClient instance was created without the authentication filter. The token was never attached to requests, so the payment gateway returned 401, which was swallowed by a generic error handler that logged nothing.
Fix
Added an ExchangeFilterFunction that injects the OAuth2 token into every request. Also added a logging filter to capture request/response details.
Key lesson
  • Always add authentication as a filter, not in service code.
  • Log outgoing request details (method, URI, headers) in development/staging.
  • Never silently swallow HTTP errors; at minimum log them.
  • Test WebClient filters with WireMock to simulate real scenarios.
  • Use a dedicated logging filter that can be toggled via configuration.
Production debug guideSymptom to Action4 entries
Symptom · 01
Requests are not reaching the external service (no outbound traffic in logs)
Fix
Check if the filter chain is broken. Add a logging filter at the beginning to confirm requests are being processed. Verify that no filter is short-circuiting the chain by returning a response without calling chain.filter().
Symptom · 02
Authentication headers are missing from requests
Fix
Ensure the auth filter runs before any retry or logging filter. Log the headers in the auth filter to confirm they are set. Check if the token is null or expired.
Symptom · 03
Retry logic is not working; requests fail without retry
Fix
Verify that the retry filter is placed after the auth filter (so retries include auth). Check the retry configuration: maxAttempts, backoff, and which exceptions trigger retry. Use doOnNext or logging to see retry attempts.
Symptom · 04
High latency or memory leaks in WebClient calls
Fix
Look for blocking calls inside filters (e.g., Thread.sleep, Future.get()). Ensure all filter logic returns reactive types. Check that request body is not buffered unnecessarily.
★ Quick Debug Cheat SheetCommon WebClient filter issues and immediate actions
No outgoing HTTP calls logged
Immediate action
Add a logging filter at the start of the filter chain
Commands
Check if WebClient is configured with a filter that returns before calling chain.filter()
Enable DEBUG logging for org.springframework.web.reactive.function.client
Fix now
Add a simple logging filter: ExchangeFilterFunction.ofRequestProcessor(request -> { log.info("Request: {} {}", request.method(), request.url()); return Mono.just(request); })
401 Unauthorized errors+
Immediate action
Verify auth filter is present and token is valid
Commands
Check if the auth filter is added to the WebClient builder
Log the Authorization header value (masked) in the filter
Fix now
Add an auth filter: ExchangeFilterFunction.ofRequestProcessor(request -> Mono.just(ClientRequest.from(request).headers(h -> h.setBearerAuth(token)).build()))
Retries not happening on 5xx errors+
Immediate action
Check retry filter placement and configuration
Commands
Ensure retry filter is after auth filter but before logging
Verify that the exception is not being caught and swallowed before retry
Fix now
Use Retry.fixedDelay(3, Duration.ofSeconds(1)) and filter on WebClientResponseException with 5xx status
Filter TypeUse CaseImplementation ComplexityPerformance Impact
LoggingDebugging, monitoringLowMedium (if body logged)
RetryResilience against transient failuresMediumLow (if backoff configured)
AuthenticationInject tokens/API keysMediumLow
Custom (Correlation ID)Distributed tracingLowLow
Custom (Metrics)ObservabilityMediumLow
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
WebClientFilterExample.javapublic class WebClientFilterExample {What Are WebClient Filters?
LoggingFilter.java@Slf4jLogging Filters
RetryFilter.java@Slf4jRetry Filters
AuthFilter.java@Slf4jAuthentication Filters
CorrelationIdFilter.java@Slf4jCustom Filters
FilterOrderTest.javapublic class FilterOrderTest {What the Official Docs Won't Tell You
WebClientFilterTest.java@SpringBootTestTesting WebClient Filters with WireMock

Key takeaways

1
WebClient filters are essential for cross-cutting concerns like logging, retry, and authentication. Order matters
place auth before retry before logging.
2
Avoid common pitfalls
don't log bodies in production, only retry idempotent methods, and never block in filters.
3
Test filters with WireMock to verify real HTTP behavior, including header injection and retry scenarios.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how you would implement a retry filter in WebClient that retries...
Q02SENIOR
How do you ensure that authentication tokens are added to every request ...
Q03SENIOR
What are the performance implications of using WebClient filters, and ho...
Q01 of 03SENIOR

Explain how you would implement a retry filter in WebClient that retries on 5xx errors with exponential backoff.

ANSWER
I would create an ExchangeFilterFunction that wraps the exchange with Reactor's Retry.backoff. Inside the filter, I check the response status: if it's 5xx, I throw a custom RetryableException. The retryWhen operator catches that exception and applies the backoff. I also filter the retry to only apply to idempotent methods (GET, PUT, DELETE).
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use multiple filters with WebClient?
02
How do I log the request body in a WebClient filter?
03
What's the difference between `ExchangeFilterFunction` and `ExchangeFunction`?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

6 min read · try the examples if you haven't

Previous
Spring WebClient Requests with Query Parameters and Path Variables
117 / 121 · Spring Boot
Next
Spring WebClient and OAuth2 Support: Client Credentials and Authorization Code Flow