How would you implement retry and request/response logging as exchange filters, and what pitfalls arise?
answer
- next.exchange(request).retryWhen(Retry.backoff(...))
- retryWhen resubscribes = re-issues call
- reading body consumes the stream
- retry needs idempotency + replayable body
- redact Authorization; never block
basics
~20 sWrite a filter that calls next.exchange(request) and adds .retryWhen(...) for retries, or logs request details before/after. Main pitfalls: reading the body inside a filter consumes it, and retrying isn't safe when the request body can't be replayed.
solid answer
~40 sFor retries, a filter returns next.exchange(request).retryWhen(Retry.backoff(maxAttempts, minBackoff).filter(this::isRetryable)) — Reactor's retryWhen resubscribes to the exchange Mono, re-issuing the call with backoff, and you scope it to idempotent/transient failures. For logging, a filter logs method/URL/headers before calling next.exchange and logs status/headers on the returned response, ideally with doOnNext or ofResponseProcessor. The big pitfalls: (1) reading the request or response body inside a filter consumes/buffers it, so naive body logging breaks the real call or forces you to re-wrap the body; prefer logging metadata, or use ExchangeStrategies/codec-level logging. (2) Retrying replays the request Publisher — if the body is a non-replayable stream or the operation isn't idempotent, retries can corrupt data or double-submit. (3) Never block; keep everything reactive. (4) Redact Authorization and secrets when logging headers.
code
java · 15 linesExchangeFilterFunction retryOnTransient = (request, next) ->
next.exchange(request)
.retryWhen(Retry.backoff(3, Duration.ofMillis(200))
.filter(ex -> ex instanceof WebClientRequestException)
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
ExchangeFilterFunction logRequest = ExchangeFilterFunction.ofRequestProcessor(req -> {
log.info("--> {} {}", req.method(), req.url()); // metadata only, no body read
return Mono.just(req);
});
WebClient client = WebClient.builder()
.filter(retryOnTransient) // outer: each attempt goes through logging below
.filter(logRequest)
.build();go deeper
Know retries use retryWhen on the exchange Mono and that filters can log request/response metadata.
Explain Retry.backoff, scoping retries to transient errors, and redacting secrets.
Articulate body-consumption and idempotency/replayability pitfalls and the non-blocking constraint.
Decide when to use filters vs. Resilience4j/wiretap, and standardize retry+logging policy across services.
## Retry as a filter Because WebClient is Reactor-based, a filter can wrap the exchange `Mono` with Reactor's retry operators: ```java ExchangeFilterFunction retry = (request, next) -> next.exchange(request) .retryWhen(Retry.backoff(3, Duration.ofMillis(200)) .filter(ex -> ex instanceof PrematureCloseException || ex instanceof WebClientRequestException)); ``` - `next.exchange(request)` returns a cold `Mono<ClientResponse>`; `retryWhen` **resubscribes** to it on failure, which re-issues the HTTP call. - `Retry.backoff(maxAttempts, minBackoff)` gives exponential backoff with jitter; `.filter(...)` restricts retries to **transient** errors. - To retry on **HTTP status** (e.g., 503) rather than exceptions, inspect `response.statusCode()` and, if retryable, either return an error to trigger `retryWhen` or release the body first. ### Retry pitfalls - **Idempotency:** retrying a non-idempotent POST can double-submit. Restrict to safe methods or use idempotency keys. - **Body replay:** if the request body comes from a one-shot `Publisher` (e.g., a streamed upload), a retry may re-subscribe to an already-consumed source and fail or send an empty body. Use a materialized/replayable body. - **Response body release:** if you consume the response to decide retry, you must release/close it (`response.releaseBody()` or `bodyToMono(...)`) or you leak connections. ## Logging as a filter ```java ExchangeFilterFunction logRequest = ExchangeFilterFunction.ofRequestProcessor(request -> { log.info("--> {} {}", request.method(), request.url()); request.headers().forEach((name, values) -> { if (!name.equalsIgnoreCase("Authorization")) log.info("{}: {}", name, values); }); return Mono.just(request); }); ExchangeFilterFunction logResponse = ExchangeFilterFunction.ofResponseProcessor(response -> { log.info("<-- {}", response.statusCode()); return Mono.just(response); }); ``` ### Logging pitfalls - **Body consumption:** `ClientResponse.bodyToMono(...)` or reading the request body **consumes the reactive stream**. If a logging filter reads the body just to print it, the actual client code can no longer read it (or the request body is already drained). To log bodies you must buffer and re-emit them — non-trivial. Often it's better to enable WebClient/Reactor Netty **wiretap** or codec/`ExchangeStrategies` logging, or log only metadata. - **Secret leakage:** never log `Authorization`, `Cookie`, API keys — redact them. - **Volume/perf:** logging every header/body at INFO in production is expensive; use DEBUG/trace and sampling. ## General filter rules - Stay **non-blocking** — no `.block()` or synchronous I/O. - Filters are **stateless and shared**; don't store per-request state in instance fields. - **Ordering** matters: a retry filter should typically be outer to logging if you want each attempt logged, or inner if you want to log once (see the ordering question). ## When to use filters vs. alternatives - Cross-cutting, every-request logic (auth, correlation, coarse retry) -> filters. - Rich resilience (circuit breaker, bulkhead, rate limiter) -> prefer Resilience4j operators, which compose with the Mono, sometimes inside a filter. - Deep wire logging -> Reactor Netty wiretap / codec logging rather than a hand-rolled body-reading filter.
- Why is logging the response body inside a filter risky?Reading the body (e.g., bodyToMono) consumes the reactive stream, so downstream client code can no longer read it. You'd have to buffer and re-emit the body. Prefer logging metadata or use Reactor Netty wiretap.
- What makes a request unsafe to retry in a filter?Non-idempotent operations (a POST that creates a resource) can double-submit, and a non-replayable request body Publisher may be empty on the second subscription. Restrict retries to idempotent calls with materialized bodies.
saying these in an interview costs you the question
- Claiming you can freely read the response body in a filter for logging with no consequences
- Retrying all requests including non-idempotent POSTs without idempotency keys
- Using Thread.sleep / .block() for backoff instead of reactive Retry.backoff
- Logging Authorization headers or tokens in plaintext