skip to content

How would you design a robust, cross-cutting error-handling strategy for many downstream RestClient calls, and how does exchange() fit in?

level: principalimportance: should knowfreq 38%

answer

  1. one client bean per integration + defaultStatusHandler
  2. translate to small typed exception vocabulary
  3. exchange() = full control, no default status handling
  4. recoverable (5xx/429/IO) vs non-recoverable (4xx)
  5. idempotency before retrying writes; @ControllerAdvice at the edge

basics

~20 s

Centralize policy on the client builder (defaultStatusHandler) to translate 4xx/5xx into a small set of domain exceptions with the body attached; distinguish those from ResourceAccessException (I/O). Use exchange() where you need full manual status control, and combine with retries/circuit breakers.

solid answer

~40 s

I'd stop scattering try/catch and instead standardize per-integration RestClient beans configured via builder().defaultStatusHandler(...), mapping status families into a small, typed exception vocabulary (e.g., DownstreamClientException for 4xx you can't recover, RetryableDownstreamException for 5xx/429/timeout) with status, headers, and buffered body carried along. I'd keep ResourceAccessException (I/O, no response) in the retryable bucket since the request may not have been processed — mindful of idempotency. onStatus handles endpoint-specific exceptions (404 -> Optional.empty). Where I need full control — conditional GETs, streaming, or returning Optional without exceptions — I use exchange(), which skips default status handlers and hands me the raw ClientHttpResponse to branch on. Around the client I layer resilience (Resilience4j retry/circuit breaker/timeout) keyed on the exception taxonomy, plus correlation-id logging and metrics in the handler. A @ControllerAdvice then maps my domain exceptions to outward responses.

code

java · 31 lines
java
// Per-integration client with a uniform error-translation policy.
@Bean
RestClient catalogClient(RestClient.Builder builder) {
    return builder
        .baseUrl("https://catalog.internal")
        .requestFactory(clientHttpRequestFactory()) // connect/read timeouts set here
        .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
            HttpStatusCode status = response.getStatusCode();
            String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
            if (status.is5xxServerError() || status.value() == 429) {
                throw new RecoverableDownstreamException(status, body,
                        response.getHeaders().getFirst("Retry-After"));
            }
            throw new NonRecoverableDownstreamException(status, body);
        })
        .build();
}

// exchange(): full manual control, no default status handling -> 404 as Optional.empty()
Optional<Product> findProduct(String sku) {
    return catalogClient.get().uri("/products/{sku}", sku)
        .exchange((request, response) -> {
            if (response.getStatusCode().value() == 404) {
                return Optional.<Product>empty();
            }
            if (response.getStatusCode().isError()) {
                throw new NonRecoverableDownstreamException(response.getStatusCode(), "");
            }
            return Optional.of(response.bodyTo(Product.class));
        });
}

go deeper

for a junior

Can catch and rethrow; unlikely to design cross-cutting policy.

for a middle

Centralizes some handling but may miss idempotency and the I/O-vs-status distinction.

for a senior

Builds a translation boundary with defaultStatusHandler and uses exchange() deliberately.

for a principal

Owns the full strategy: exception taxonomy, resilience patterns, idempotency, timeouts, observability, and edge mapping via @ControllerAdvice.

## The problem at scale With dozens of downstream calls, ad-hoc `try/catch (HttpClientErrorException ...)` blocks duplicate logic, leak transport concerns into business code, and drift. The goal is a **thin, uniform translation boundary** so business code sees a *small, meaningful exception vocabulary*, not raw HTTP types. ## Layer 1 — per-client policy via the builder Create one configured `RestClient` bean per integration and attach a **`defaultStatusHandler(...)`** (a `ResponseErrorHandler` or predicate+handler). There you: - classify status → domain exception, - attach `getStatusCode()`, headers (e.g., `Retry-After`), and the buffered body (`getResponseBodyAs(ProblemDetail.class)` when structured), - emit metrics and correlation-id logs. A sensible taxonomy: `RecoverableDownstreamException` (5xx, 429, and `ResourceAccessException`) vs `NonRecoverableDownstreamException` (most 4xx like 400/404/409) vs domain-specific ones (e.g., `NotFound` → empty). ## Layer 2 — endpoint specifics via onStatus / exchange - **`onStatus`** for a single endpoint's quirks (treat 404 as empty, unwrap a vendor error envelope). - **`exchange()`** when you need *full manual control*: it deliberately **does not apply default status handlers**, giving you the raw `ClientHttpResponse` (`getStatusCode()`, `getBody()`) so you can branch and, e.g., return `Optional.empty()` for 404 **without throwing at all** — cleaner than swallowing via a no-op handler. Use it for conditional requests (304 Not Modified), partial content, or streaming where exception-based flow is awkward. ## Layer 3 — resilience around the call Wrap calls with **Resilience4j** (or Spring Retry): retry + exponential backoff for the *recoverable* bucket, circuit breaker to shed load when a dependency is down, and per-call timeouts. Keying retry policy on your exception taxonomy keeps it declarative: retry `RecoverableDownstreamException`, never retry `NonRecoverableDownstreamException`. **Idempotency matters** — only retry non-idempotent writes if the API supports idempotency keys, because a `ResourceAccessException` might mean the server *did* process the request. ## Layer 4 — surface to your own API A `@ControllerAdvice`/`@ExceptionHandler` (or `ResponseEntityExceptionHandler`) maps your domain exceptions to outward `ProblemDetail` responses, so a downstream 500 becomes a deliberate 502/503 to *your* caller rather than an accidental 500. ## The I/O vs status distinction (critical) - **Status exceptions** (`Http*ErrorException`): the server answered; the operation's outcome is known. - **`ResourceAccessException`**: connection refused, DNS, socket/read timeout — outcome *unknown*. Treat as retryable but guard writes with idempotency. ## Observability & config - Set connect/read timeouts on the request factory — without them a hung dependency ties up threads. - Log status, latency, correlation id, and a *bounded* slice of the error body (avoid dumping huge/HTML bodies or secrets). - Emit metrics tagged by dependency + status class for dashboards/alerts. ## Anti-patterns to avoid - Catching `Exception` and swallowing — hides outages. - Replacing the default handler with one whose `hasError` always returns false — silently drops all errors. - Retrying 4xx (won't succeed) or retrying non-idempotent writes blindly. - Letting raw `HttpServerErrorException` bubble into controllers, producing accidental 500s.

  • Why is exchange() preferable to a no-op onStatus handler for turning 404 into Optional.empty()?
    exchange() skips default status handling and gives the raw response, so you branch on 404 and return Optional.empty() without ever throwing or relying on swallow semantics; a no-op onStatus still proceeds into body conversion, which is murkier and may fail on a non-conforming error body.
  • Which failures are safe to retry, and what's the catch with ResourceAccessException on writes?
    5xx, 429, and transient I/O are generally retryable for idempotent operations. A ResourceAccessException on a non-idempotent write is risky because the server may have processed it despite the client seeing no response — retry only with idempotency keys.
  • How do you keep a downstream 500 from becoming your own accidental 500?
    Translate it to a domain exception at the client boundary and map that in @ControllerAdvice to a deliberate status (e.g., 502/503 with a ProblemDetail), rather than letting HttpServerErrorException bubble to the default 500 handler.

saying these in an interview costs you the question

  • Blindly retrying all failures including 4xx and non-idempotent writes.
  • Swallowing errors with a catch-all that returns null, hiding outages.
  • No connect/read timeouts, so a hung dependency exhausts threads.
  • Letting raw Http*ErrorException reach controllers and become accidental 500s.
  • Assuming exchange() applies the default status handlers (it does not).

context