skip to content

Design a resilient, reusable exchange-filter chain (tracing, token refresh, retry, logging) for a shared WebClient. What ordering and correctness concerns drive it?

level: principalimportance: nice to knowfreq 22%

answer

  1. outer->inner: tracing, retry, auth, logging
  2. one trace span wraps all retry attempts
  3. auth inside retry -> fresh token per attempt
  4. stateless + non-blocking + single-flight token cache
  5. package as WebClientCustomizer / shared bean

basics

~20 s

Put tracing outermost, then retry, then auth/token-refresh, then logging closest to the call — so each retry attempt re-runs auth and logging under one trace span. Keep filters stateless, non-blocking, and configure them once on a shared WebClient bean.

solid answer

~50 s

I define one canonical builder configuration shared by all clients. Ordering (outermost first): tracing/correlation so one span encloses all retry attempts; then retry so a fresh token and log line happen per attempt; then auth/token-refresh via ofRequestProcessor that reactively fetches or refreshes a bearer token (and can react to a 401 to force refresh); then logging innermost so it observes the fully decorated request and real status. Correctness drivers: filters must be stateless and thread-safe since one WebClient serves many concurrent requests; everything non-blocking (token lookup returns a Mono, cached with single-flight to avoid stampedes); retries scoped to idempotent methods and transient failures with replayable bodies; secrets redacted in logs; response bodies released when consumed for retry decisions. I'd push heavy resilience (circuit breaker, rate limit) to Resilience4j operators composed into the chain, and expose the config as a Spring-managed WebClient bean or WebClientCustomizer so every service inherits identical behavior.

code

java · 19 lines
java
@Bean
WebClientCustomizer resilientFilters(TokenService tokens, Tracer tracer) {
    return builder -> builder.filters(filters -> {
        // order: first added = outermost
        filters.add(tracingFilter(tracer));   // 1. span wraps all attempts
        filters.add(retryFilter());            // 2. retry
        filters.add(ExchangeFilterFunction.ofRequestProcessor(req ->
            tokens.currentToken()              // 3. fresh token per attempt (cached, single-flight)
                .map(t -> ClientRequest.from(req)
                    .header("Authorization", "Bearer " + t).build())));
        filters.add(loggingFilter());          // 4. sees decorated request + real status
    });
}

private ExchangeFilterFunction retryFilter() {
    return (request, next) -> next.exchange(request)
        .retryWhen(Retry.backoff(3, Duration.ofMillis(200))
            .filter(ex -> ex instanceof WebClientRequestException));
}

go deeper

for a junior

Recognize that filters are the place for shared concerns and are configured once on the builder.

for a middle

Order tracing/retry/auth/logging sensibly and keep filters non-blocking.

for a senior

Reason about idempotency, body replay, response release, and secret redaction across the chain.

for a principal

Standardize the stack via WebClientCustomizer/bean, handle Reactor Context propagation and single-flight token caching, and delegate heavy resilience to Resilience4j.

## Goal A platform-level, reusable filter stack so every service's outbound HTTP behaves consistently: traced, authenticated, retried, and logged — configured once, not per call site. ## Recommended ordering (outermost -> innermost) Recall: **first added = outermost** (request first, response last). 1. **Tracing / correlation (outermost).** Establishes/propagates a trace or correlation ID so a **single span encloses all retry attempts**. If it were inner to retry, each attempt would look like a separate top-level call. 2. **Retry.** Wrapping auth and logging means each attempt gets a **fresh token** and its own **log line**. `next.exchange(request).retryWhen(Retry.backoff(...).filter(isTransient))`. 3. **Auth / token refresh.** An `ofRequestProcessor` that reactively obtains a bearer token (`tokenService.token()` -> `Mono<String>`). On a `401`, it can trigger a refresh and let the retry layer above re-attempt. Because it's inside retry, a refreshed token is applied on the next attempt. 4. **Logging (innermost).** Sees the **fully decorated** request (with trace + auth headers) and the **actual** status/latency closest to the wire. Ordering is not one-size-fits-all: if you want to log **once** regardless of retries, move logging **outside** retry. If metrics should measure total time including retries, put timing outermost; to measure per-attempt, put it inside. ## Correctness and operational concerns - **Statelessness & thread-safety.** One `WebClient` (and its filters) is shared across many concurrent requests on few event-loop threads. Filters must not hold per-request mutable state in instance fields; carry state via the request/context (`contextWrite` / Reactor Context) instead. - **Non-blocking.** Token lookups, refresh, and backoff must be reactive. Never `.block()`. Cache tokens and use **single-flight** (e.g., a shared `Mono.cache()` or a `Mono` guarded so concurrent callers await one refresh) to avoid a **refresh stampede**. - **Idempotency & body replay.** Retries are safe only for idempotent operations with **replayable** request bodies; a streamed one-shot body will be empty on resubscription. Gate retries by method and materialize bodies. - **Response body lifecycle.** If a filter consumes the response to decide retry, it must release it (`releaseBody()`/consume fully) to avoid connection leaks. - **Security.** Redact `Authorization`, `Cookie`, and secrets in logs; ensure token filter doesn't log the token. - **Reactor Context propagation.** Trace/correlation and security context ride the Reactor `Context`, not `ThreadLocal`; ensure filters propagate it (e.g., Micrometer context propagation). ## Packaging for reuse - Expose a **`WebClient` bean** or a **`WebClientCustomizer`** (auto-applied to `WebClient.Builder` beans) so every client inherits the same stack. Use `builder.mutate()` for per-client tweaks (base URL, extra filter) without duplicating the core chain. - For richer resilience — **circuit breaker, bulkhead, rate limiter** — prefer **Resilience4j** operators composed onto the exchange `Mono` (optionally inside a filter) rather than hand-rolling them. - For deep wire/body logging, prefer **Reactor Netty wiretap** or codec logging over body-reading filters, which risk consuming streams. ## When this is overkill For a single internal call or a script, a couple of filters or even `defaultHeaders` suffice. The full platform stack pays off when many services share an HTTP egress standard and you need uniform observability, auth, and resilience.

  • Why put the tracing filter outermost rather than innermost?
    So a single trace span encloses every retry attempt and all inner filter work. Innermost, each retry would appear as a disconnected span and you'd lose the parent-child relationship for the whole logical call.
  • How do you prevent a token-refresh stampede when many concurrent requests hit an expired token?
    Use single-flight caching: share one in-flight refresh Mono (e.g., Mono.cache with expiry, or a guarded refresh) so concurrent callers await the same refresh instead of each triggering their own, then all reuse the new token.

saying these in an interview costs you the question

  • Storing per-request state in filter instance fields (breaks under concurrency on shared threads)
  • Blocking to fetch/refresh a token instead of returning a Mono
  • Putting each token refresh per-request with no caching, causing refresh stampedes
  • Placing tracing innermost so retries fragment the trace
  • Rebuilding a new WebClient per request instead of sharing a configured bean

context