Spring WebClient Filters: Logging, Retry, Authentication, and Custom Filters
Master WebClient filters for logging, retries, and auth.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Java 17+
- ✓Spring Boot 3.x
- ✓Basic understanding of reactive programming with Project Reactor
- ✓Familiarity with WebClient basics
- WebClient filters allow you to intercept and modify requests/responses in a reactive pipeline.
- Use
ExchangeFilterFunctionto 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.
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).
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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>.
Here's the simplest filter you'll ever write:
``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 . I've used that last trick for mocking in tests.next.exchange()
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.
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.
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.
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.
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); }); } } ```
And a metrics filter using Micrometer:
```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.
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 calls matters. If you do filter()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 and then retrieve()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.
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.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.
First, add WireMock to your test dependencies:
``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.
The Silent 401: How a Missing Auth Filter Cost a Fintech $10k
ExchangeFilterFunction that injects the OAuth2 token into every request. Also added a logging filter to capture request/response details.- 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.
chain.filter().maxAttempts, backoff, and which exceptions trigger retry. Use doOnNext or logging to see retry attempts.Thread.sleep, Future.get()). Ensure all filter logic returns reactive types. Check that request body is not buffered unnecessarily.Check if WebClient is configured with a filter that returns before calling chain.filter()Enable DEBUG logging for org.springframework.web.reactive.function.clientExchangeFilterFunction.ofRequestProcessor(request -> { log.info("Request: {} {}", request.method(), request.url()); return Mono.just(request); })| File | Command / Code | Purpose |
|---|---|---|
| WebClientFilterExample.java | public class WebClientFilterExample { | What Are WebClient Filters? |
| LoggingFilter.java | @Slf4j | Logging Filters |
| RetryFilter.java | @Slf4j | Retry Filters |
| AuthFilter.java | @Slf4j | Authentication Filters |
| CorrelationIdFilter.java | @Slf4j | Custom Filters |
| FilterOrderTest.java | public class FilterOrderTest { | What the Official Docs Won't Tell You |
| WebClientFilterTest.java | @SpringBootTest | Testing WebClient Filters with WireMock |
Key takeaways
Interview Questions on This Topic
Explain how you would implement a retry filter in WebClient that retries on 5xx errors with exponential backoff.
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).Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't