How do you customize error handling for specific status codes with RestClient's onStatus handlers?
answer
- retrieve().onStatus(predicate, handler)
- handler gets HttpRequest + ClientHttpResponse
- first matching predicate wins; order matters
- unmatched errors still hit default handler
- builder-wide: defaultStatusHandler(...)
basics
~20 sOn the retrieve() path, chain .onStatus(predicate, handler). The predicate tests the HttpStatusCode; the handler receives the request and ClientHttpResponse so you can throw your own exception or read the body. Matching handlers replace the default throw.
solid answer
~40 sRestClient's ResponseSpec has onStatus(Predicate<HttpStatusCode>, ErrorHandler). You register one or more handlers before calling body()/toEntity(). When the response arrives, Spring evaluates your predicates in order; the first match runs its handler, which gets the HttpRequest and the raw ClientHttpResponse. Inside you can read the error body, log, and throw a domain-specific exception — or do nothing to swallow the error. If no onStatus predicate matches, the default handler still applies (4xx/5xx throw). Common patterns: onStatus(HttpStatusCode::is4xxClientError, ...) to map to your own exceptions, or onStatus(status -> status.value() == 404, ...) to treat a specific code as an empty result. For a builder-wide default across all requests, use RestClient.Builder.defaultStatusHandler(...). Use exchange() instead when you need full manual control and want to suppress default handling entirely.
code
java · 19 linesUser user = restClient.get()
.uri("/users/{id}", id)
.retrieve()
// specific first, broad later (order matters)
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new UserNotFoundException(id);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
throw new DownstreamUnavailableException(response.getStatusCode(), body);
})
.body(User.class);
// Builder-wide default for every request from this client:
RestClient client = RestClient.builder()
.defaultStatusHandler(HttpStatusCode::isError, (req, res) -> {
throw new IntegrationException(res.getStatusCode());
})
.build();go deeper
Aware onStatus exists to customize errors but may not know ordering/default interaction.
Can write onStatus mapping to domain exceptions and knows unmatched errors still throw by default.
Uses defaultStatusHandler for cross-cutting policy and knows when to drop to exchange().
Standardizes a client error-translation layer (domain exceptions) across all integrations to keep controllers clean.
## Where onStatus lives `RestClient.get()...retrieve()` returns a `RestClient.ResponseSpec`. On that spec you can chain **`onStatus(Predicate<HttpStatusCode> statusPredicate, RestClient.ResponseSpec.ErrorHandler errorHandler)`**. You may register several; they're evaluated **in registration order**, and the **first matching** predicate wins. `ErrorHandler` is a functional interface: `void handle(HttpRequest request, ClientHttpResponse response) throws IOException`. It gives you the outgoing request (URI, method, headers) and the raw response so you can read the status, headers, and body, then decide what to do: - **throw** a custom/domain exception (most common), - **log** and rethrow, - or **return normally** to *swallow* the error — after which `body(...)` proceeds to convert whatever body is present. ## Interaction with the default handler Key subtlety: registering `onStatus` does **not** disable the default handler globally. Spring applies **your** matching handler for statuses you predicate on; for any error status you *didn't* match, the built-in `DefaultResponseErrorHandler` still runs and throws. So if you only handle 404 and a 500 comes back, the 500 still throws `HttpServerErrorException` — unless you also register a broader predicate. ## Reading the body inside a handler The handler receives a `ClientHttpResponse`; use `response.getStatusCode()`, `response.getHeaders()`, and `response.getBody()` (an InputStream). `StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8)` is a common way to capture the payload before throwing. ## Builder-wide defaults To apply a policy to *every* request from a client, configure it on the builder: **`RestClient.builder().defaultStatusHandler(predicate, errorHandler)`** (or `defaultStatusHandler(ResponseErrorHandler)`). This replaces/augments the default across the client instead of per-call. ## Swallowing vs. mapping - **Map**: `onStatus(HttpStatusCode::is5xxServerError, (req, res) -> { throw new DownstreamUnavailableException(...); })`. - **Swallow 404 -> Optional.empty()**: register `onStatus(status -> status.value() == 404, (req, res) -> {})` (no-op) and then interpret an empty/absent body as "not found" — or better, use `.exchange()` and branch on status to return `Optional`. ## Gotchas - Order matters: a broad `is4xxClientError` predicate registered before a specific `== 404` will shadow the specific one. - A no-op handler doesn't magically produce a null object; `body(...)` still tries to deserialize whatever came back. For clean "empty on 404", `exchange()` is usually clearer. - `onStatus` is a `retrieve()` concept; `exchange()` bypasses status handlers entirely, so combining both is redundant. - The predicate takes `HttpStatusCode`, not an int — use helpers like `HttpStatusCode::is4xxClientError` or `status.value() == 429`.
- If you register onStatus only for 4xx and the server returns 500, what happens?Your 4xx handler doesn't match, so the default DefaultResponseErrorHandler still runs and throws HttpServerErrorException. onStatus doesn't disable the default for unmatched statuses.
- How do you apply the same error policy to every request from a client?Configure RestClient.builder().defaultStatusHandler(predicate, errorHandler) (or pass a full ResponseErrorHandler) so it applies client-wide instead of per call.
saying these in an interview costs you the question
- Believing onStatus disables all default handling — it only covers statuses your predicate matches.
- Assuming a no-op onStatus handler returns null/empty automatically.
- Registering a broad predicate before a specific one and expecting the specific one to run.