skip to content

What is the difference between retrieve() and exchange() on RestClient, and how does error/status handling differ between them?

level: seniorimportance: must knowfreq 55%

answer

  1. retrieve() = high-level, auto-throws 4xx/5xx
  2. exchange() = raw request+response, no auto-throw
  3. onStatus(predicate, handler) to customize
  4. HttpClientErrorException 4xx / HttpServerErrorException 5xx
  5. exchange: read inside the function, response closed after

basics

~10 s

retrieve() is the high-level path that auto-throws on 4xx/5xx and lets you extract body/entity; exchange() gives you the raw request+response to inspect status/headers/body yourself and, by default, does NOT throw on error status.

solid answer

~40 s

retrieve() returns a ResponseSpec: it decodes the body for you via body(Class)/toEntity(Class) and, by default, throws a RestClientResponseException subclass (HttpClientErrorException for 4xx, HttpServerErrorException for 5xx) on error status. You customize that with onStatus(predicate, handler) to map specific statuses to your own exceptions or to swallow them. exchange(ExchangeFunction) is the low-level escape hatch: you receive the request and a ClientHttpResponse and take full control — read the raw status, headers and stream, branch on status yourself, and return whatever you want. Crucially, exchange() does NOT apply the default status handlers, so it never auto-throws; you own error handling. Use retrieve() for the common case; reach for exchange() when you must inspect headers/status before deciding how to read the body, handle non-standard error shapes, or stream the response manually.

code

java · 24 lines
java
// retrieve() with custom status handling
try {
    User user = restClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .onStatus(status -> status.value() == 404,
                      (req, res) -> { throw new UserNotFoundException(id); })
            .body(User.class);
} catch (HttpServerErrorException e) {
    // default handler threw for any 5xx we didn't intercept
    log.warn("upstream 5xx: {}", e.getResponseBodyAsString());
}

// exchange() — full control, no auto-throw
Optional<User> maybeUser = restClient.get()
        .uri("/users/{id}", id)
        .exchange((request, response) -> {
            HttpStatusCode sc = response.getStatusCode();
            if (sc.is2xxSuccessful()) {
                return Optional.of(mapper.readValue(response.getBody(), User.class));
            }
            if (sc.value() == 404) return Optional.empty();
            throw new IllegalStateException("Unexpected: " + sc);
        });

go deeper

for a junior

Know retrieve() is the normal path and throws on error status; exchange() is advanced.

for a middle

Explain onStatus, and the HttpClientErrorException/HttpServerErrorException thrown by retrieve().

for a senior

Contrast default-throw of retrieve() vs no-throw of exchange(); pick the right one and manage the response stream inside exchange.

for a principal

Design a consistent client-error strategy (domain-exception mapping, resilience/retry, observability) and codify whether teams use retrieve()+onStatus vs exchange() for non-standard upstreams.

## Two ways to consume the response After building the request you call either **`retrieve()`** or **`exchange(...)`**. ### `retrieve()` — the high-level path Returns a **`RestClient.ResponseSpec`**. On it you call: - `body(Class<T>)` / `body(ParameterizedTypeReference<T>)` → deserialized body only. - `toEntity(Class<T>)` → `ResponseEntity<T>` (status + headers + body). - `toBodilessEntity()` → `ResponseEntity<Void>`. **Default error behavior:** if the response status is **4xx or 5xx**, `retrieve()` throws a **`RestClientResponseException`** subclass: - **`HttpClientErrorException`** for 4xx, - **`HttpServerErrorException`** for 5xx, - **`UnknownHttpStatusCodeException`** for non-standard codes. The exception carries the status code, response headers, and raw body (`getResponseBodyAsString()`). **Customizing status handling** with `onStatus`: ```java User u = restClient.get().uri("/users/{id}", id) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> { if (res.getStatusCode().value() == 404) throw new UserNotFoundException(id); throw new MyClientException(res.getStatusCode()); }) .body(User.class); ``` `onStatus(Predicate<HttpStatusCode>, ErrorHandler)` lets you match statuses and either throw a domain exception or return normally (swallowing the default throw). Multiple `onStatus` handlers evaluate in order. ### `exchange(...)` — the low-level escape hatch `exchange(ExchangeFunction<T>)` (also `exchangeForRequiredValue`) hands you the executed request and the raw **`ClientHttpResponse`** and lets you return any value: ```java User u = restClient.get().uri("/users/{id}", id) .exchange((request, response) -> { if (response.getStatusCode().is2xxSuccessful()) { return objectMapper.readValue(response.getBody(), User.class); } else if (response.getStatusCode().value() == 404) { return null; } throw new MyException(response.getStatusCode()); }); ``` **Key difference:** by **default `exchange()` does NOT invoke the standard status handlers**, so it never auto-throws on 4xx/5xx — *you* decide. (There's a `boolean` overload to opt back into default handlers if you want them.) With `exchange`, resource management of the response stream is handled for you once the function returns, but you must read what you need inside the function — the response is closed afterward. ## When to use which - **`retrieve()`**: 90% of calls — clean body extraction with sensible default error semantics you can tune with `onStatus`. - **`exchange()`**: you need to inspect **status/headers before** deciding how to read the body, handle **non-JSON or empty error bodies**, implement **conditional logic** (e.g. 304 Not Modified), or do **manual streaming**. ## Gotchas - Forgetting that `exchange()` won't throw on 500 — a naive port from `retrieve()` can silently ignore server errors. - Inside `exchange`, don't stash the `ClientHttpResponse` for later use; it's only valid within the function. - `onStatus` handlers that neither throw nor consume still fall through to body extraction — make sure a matched error path throws or explicitly handles. - The default handler reads and buffers the error body into the exception; very large error bodies are still read.

  • Does exchange() throw on a 500 response by default?
    No. exchange() bypasses the default status handlers, so 4xx/5xx do not auto-throw — you must inspect response.getStatusCode() yourself (or use the overload that re-enables default handling).
  • Which exceptions does retrieve() throw for error statuses, and what do they carry?
    HttpClientErrorException for 4xx and HttpServerErrorException for 5xx (both extend RestClientResponseException), carrying status code, response headers, and the raw response body string.
  • How do you map a 404 to your own domain exception without affecting other statuses?
    Add .onStatus(status -> status.value() == 404, (req,res) -> { throw new UserNotFoundException(); }) before body(); other statuses still hit the default handler.

saying these in an interview costs you the question

  • Claiming exchange() auto-throws on 4xx/5xx like retrieve()
  • Thinking retrieve() returns raw ClientHttpResponse
  • Believing onStatus can only swallow errors, not remap them
  • Storing the ClientHttpResponse from exchange() for use after the function returns

context