Handling Errors from Remote Services
Know which exceptions RestClient throws for error statuses and network failures, translate them into your own, and answer your clients honestly.
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> { throw new MyException(...); })
.body(T.class);
catch (ResourceAccessException ex) // network failure or timeout
catch (HttpServerErrorException ex) // 5xxEvery remote call can fail in two different ways, and they need different handling.
- The server answered with an error status. By default
RestClientthrowsHttpClientErrorExceptionfor 4xx andHttpServerErrorExceptionfor 5xx. Both extendHttpStatusCodeExceptionandRestClientResponseException, which carry the status, headers and body. There are subclasses per status, such asHttpClientErrorException.NotFound. - No usable answer arrived. Connection refused, DNS failure, a read timeout: these surface as
ResourceAccessException, wrapping the underlyingIOException.
All of them extend RestClientException, which is unchecked. If you do nothing, a 404 from a downstream service travels up through your service into your controller and your API answers 500. That is the most common bug here.
Translate at the edge of the integration
The client class should turn HTTP failures into exceptions that mean something in your domain. onStatus on the response spec runs before the body is read:
rest.get().uri("/products/{sku}", sku)
.retrieve()
.onStatus(status -> status.value() == 404, (req, res) -> {
throw new ProductNotFoundException(sku);
})
.requiredBody(Product.class);For rules that apply to every call on one client, register them once with defaultStatusHandler on the builder. When a 404 is a normal answer rather than an error, use exchange(...), which hands you the raw response and skips the default status handling, and return an Optional.
Decide what your own client sees
- The upstream says the thing does not exist: often a 404 from you too, but phrased in your terms.
- The upstream is down, slow or returns 5xx: answer
503 Service Unavailableor502 Bad Gateway, not 500, so callers and load balancers know the fault is temporary and elsewhere. - The upstream rejected your request with an unexpected 4xx: that is a bug on your side. Log it loudly, answer 502.
Never copy the upstream response body into your own error response. It can contain internal host names, stack traces or another customer's data. Log it at WARN with the status, and return a short Problem Details body of your own.
If the upstream returns Problem Details itself, getResponseBodyAs(ProblemDetail.class) on the exception parses the body so you can branch on its type or a custom property rather than on message text.
Example
import java.math.BigDecimal;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
record Product(String sku, String name, BigDecimal price) {
}
class ProductNotFoundException extends RuntimeException {
ProductNotFoundException(String sku) {
super("No product with SKU " + sku);
}
}
class CatalogUnavailableException extends RuntimeException {
CatalogUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
public class CatalogClient {
private final RestClient rest;
CatalogClient(RestClient.Builder builder, ("${app.catalog.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
public Product product(String sku) {
try {
return rest.get()
.uri("/products/{sku}", sku)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new ProductNotFoundException(sku);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new CatalogUnavailableException(
"Catalog answered " + response.getStatusCode(), null);
})
.requiredBody(Product.class);
} catch (ResourceAccessException ex) {
// connection refused, DNS failure, read timeout
throw new CatalogUnavailableException("Catalog unreachable", ex);
}
}
}
When to use it
- Turn a catalogue service's 404 into a ProductNotFoundException that your own API maps to a clear 404 for the shopper.
- Answer 503 with a Problem Details body when the payment provider times out, instead of a generic 500 with a stack trace in the logs only.
- Look up an optional customer record in a CRM where 'not found' is a normal answer and should not be logged as an error.
More examples
Treating 404 as an empty result
<code>exchange</code> gives you the status before anything is thrown, so "not found" becomes an ordinary <code>Optional.empty()</code> instead of an exception used for control flow. Every other error still becomes the standard <code>RestClientResponseException</code> through <code>createException()</code>, so you do not accidentally swallow a 500.
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
record Customer(String id, String email, String tier) {
}
@Component
public class CrmClient {
private final RestClient rest;
CrmClient(RestClient.Builder builder, @Value("${app.crm.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
public Optional<Customer> findByEmail(String email) {
return rest.get()
.uri(uri -> uri.path("/customers").queryParam("email", email).build())
.exchange((request, response) -> {
if (response.getStatusCode().value() == 404) {
return Optional.<Customer>empty();
}
if (response.getStatusCode().isError()) {
throw response.createException(); // same exception retrieve() would throw
}
return Optional.ofNullable(response.bodyTo(Customer.class));
});
}
}
Answering 503 or 502 without leaking upstream details
This advice is the safety net for anything the client classes did not translate. The full upstream body goes to the log, where operators can read it, and the API's own caller gets a short Problem Details response with no internal detail. Without the <code>HttpServerErrorException</code> handler an untranslated upstream 5xx would still reach your caller as a 500. A 503 tells load balancers and well-behaved clients that retrying later makes sense; a 502 points at the dependency rather than at the caller.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
@RestControllerAdvice
public class RemoteFailureAdvice {
private static final Logger log = LoggerFactory.getLogger(RemoteFailureAdvice.class);
@ExceptionHandler(ResourceAccessException.class)
ProblemDetail unreachable(ResourceAccessException ex) {
log.warn("Downstream service unreachable", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.SERVICE_UNAVAILABLE, "A service we depend on is not responding. Try again shortly.");
problem.setTitle("Dependency unavailable");
return problem;
}
@ExceptionHandler(HttpServerErrorException.class)
ProblemDetail upstreamFailed(HttpServerErrorException ex) {
log.warn("Downstream failed: status={} body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY, "A service we depend on failed. Try again shortly.");
}
@ExceptionHandler(HttpClientErrorException.class)
ProblemDetail rejectedByUpstream(HttpClientErrorException ex) {
// An unexpected 4xx means our request was wrong: a bug on our side.
log.error("Downstream rejected our request: status={} body={}",
ex.getStatusCode(), ex.getResponseBodyAsString());
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY, "Could not complete the request.");
}
}
Reading the upstream's Problem Details
Catching the status-specific subclass keeps the handler narrow: only a 409 is treated as a decline, everything else propagates. <code>getResponseBodyAs</code> uses the client's message converters, so a Problem Details body from another Spring service parses straight into <code>ProblemDetail</code>, extra members included. Branching on a stable code is far safer than matching on the human-readable detail text.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ProblemDetail;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
record Charge(String orderId, long amountPence, String currency) {
}
class PaymentDeclinedException extends RuntimeException {
PaymentDeclinedException(String reason) {
super("Payment declined: " + reason);
}
}
@Component
public class PaymentsClient {
private final RestClient rest;
PaymentsClient(RestClient.Builder builder, @Value("${app.payments.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
public void charge(Charge charge) {
try {
rest.post().uri("/charges").body(charge).retrieve().toBodilessEntity();
} catch (HttpClientErrorException.Conflict ex) {
ProblemDetail problem = ex.getResponseBodyAs(ProblemDetail.class);
Object reason = problem != null && problem.getProperties() != null
? problem.getProperties().get("declineCode")
: null;
throw new PaymentDeclinedException(reason != null ? reason.toString() : "unknown");
}
}
}
Discussion