RestTemplate throws an exception the moment a remote server returns a 4xx or 5xx response. Most developers expect a null or an empty object — instead they get a runtime exception that crashes their service.
Quick Answer
Catch HttpClientErrorException for 4xx errors and HttpServerErrorException for 5xx errors. Or swap RestTemplate’s default error handler so it doesn’t throw at all.
try {
ResponseEntity<User> response = restTemplate.getForEntity(url, User.class);
return response.getBody();
} catch (HttpClientErrorException.NotFound e) {
return null; // 404 — resource doesn't exist
} catch (HttpClientErrorException e) {
throw new ServiceException("Client error: " + e.getStatusCode(), e);
} catch (HttpServerErrorException e) {
throw new ServiceException("Remote server error: " + e.getStatusCode(), e);
}
Why RestTemplate Throws on 4xx and 5xx
Spring’s RestTemplate delegates error detection to a ResponseErrorHandler. The default implementation — DefaultResponseErrorHandler — calls hasError() on every response. If the HTTP status code is 4xx or 5xx, it flags the response as an error and throws:
HttpClientErrorExceptionfor 4xx responses (subclasses likeHttpClientErrorException.NotFoundfor 404,HttpClientErrorException.Unauthorizedfor 401, etc.)HttpServerErrorExceptionfor 5xx responsesUnknownHttpStatusCodeExceptionfor non-standard status codes
The stack trace usually looks like this:
org.springframework.web.client.HttpClientErrorException$NotFound: 404 Not Found: [{"message":"User not found"}]
at org.springframework.web.client.HttpClientErrorException.create(HttpClientErrorException.java:113)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:185)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:137)
at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63)
at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:942)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:891)
...
at com.example.UserService.getUser(UserService.java:34)
Use Debugly’s stack trace formatter to collapse the Spring internals and jump straight to UserService.java:34 — the line in your code that actually matters.
This behavior is intentional. The Spring team treats 4xx and 5xx as errors by design. But in practice, many services return 404 for “not found” or 409 for “conflict” as expected business responses rather than exceptional conditions. That’s where the mismatch between expectation and reality hits.
Solution 1: Catch the Exception Directly
The simplest fix is wrapping your RestTemplate call in a try-catch. The exception hierarchy is clean:
RestClientException
└── HttpStatusCodeException
├── HttpClientErrorException (4xx)
│ ├── HttpClientErrorException.BadRequest (400)
│ ├── HttpClientErrorException.Unauthorized (401)
│ ├── HttpClientErrorException.Forbidden (403)
│ ├── HttpClientErrorException.NotFound (404)
│ ├── HttpClientErrorException.Conflict (409)
│ └── HttpClientErrorException.UnprocessableEntity (422)
└── HttpServerErrorException (5xx)
├── HttpServerErrorException.InternalServerError (500)
└── HttpServerErrorException.ServiceUnavailable (503)
You can catch at any level of specificity:
@Service
public class UserService {
private final RestTemplate restTemplate;
public UserService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public Optional<User> findUser(String userId) {
String url = "https://api.example.com/users/" + userId;
try {
ResponseEntity<User> response = restTemplate.getForEntity(url, User.class);
return Optional.ofNullable(response.getBody());
} catch (HttpClientErrorException.NotFound e) {
// 404 is a valid business case — user simply doesn't exist
return Optional.empty();
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
// Auth failures — don't retry, fix the credentials
log.error("Auth error calling user API: {}", e.getStatusCode());
throw new ServiceException("Authentication failed", e);
} catch (HttpClientErrorException e) {
// Catch-all for other 4xx
log.warn("Client error for user {}: {} — {}", userId, e.getStatusCode(), e.getResponseBodyAsString());
throw new ServiceException("Unexpected client error: " + e.getStatusCode(), e);
} catch (HttpServerErrorException e) {
// Remote server blew up — safe to retry with backoff
log.error("Server error calling user API: {}", e.getStatusCode());
throw new RetryableServiceException("Remote server error", e);
} catch (ResourceAccessException e) {
// Connection refused, timeout, DNS failure
log.error("Network error calling user API", e);
throw new RetryableServiceException("Network error", e);
}
}
}
A few things worth noting here. HttpStatusCodeException.getResponseBodyAsString() gives you the raw error body from the remote service — log it, because that’s usually where the real error message lives. Also notice ResourceAccessException in the catch chain — that’s what RestTemplate throws when the TCP connection itself fails (server unreachable, timeout, etc.), and it’s a different exception family entirely.
Solution 2: Use exchange() to Avoid Exceptions Altogether
If you’re hitting an API where almost any status code is a valid response, the exchange() method lets you get a ResponseEntity back without RestTemplate ever throwing:
public ServiceResult callExternalApi(String url, Object requestBody) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Object> request = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<ApiResponse> response = restTemplate.exchange(
url,
HttpMethod.POST,
request,
ApiResponse.class
);
// You now have the status code without an exception being thrown
// Wait — this STILL throws on 4xx/5xx with the default error handler!
// Combine with Solution 3 (custom error handler) below for true no-throw behavior
return new ServiceResult(response.getStatusCode(), response.getBody());
} catch (RestClientException e) {
// Still catches network errors
throw new ServiceException("API call failed", e);
}
}
Here’s the gotcha: exchange() still uses the same ResponseErrorHandler under the hood. So it still throws on 4xx/5xx unless you replace the error handler. Using exchange() alone doesn’t suppress exceptions — it just gives you more control over request/response types.
Solution 3: Custom ResponseErrorHandler
The cleanest approach for services where 4xx/5xx codes are routine responses is swapping out the error handler entirely. You have two main strategies.
Strategy A — Suppress all errors:
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
// Never throw for any HTTP status code
restTemplate.setErrorHandler(new ResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false; // treat everything as success
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
// no-op
}
});
return restTemplate;
}
}
With this handler, every call returns a ResponseEntity. Your calling code then inspects response.getStatusCode() explicitly:
ResponseEntity<User> response = restTemplate.getForEntity(url, User.class);
if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
return Optional.empty();
} else if (response.getStatusCode().is5xxServerError()) {
throw new RetryableServiceException("Server error: " + response.getStatusCode());
}
return Optional.ofNullable(response.getBody());
Strategy B — Suppress specific status codes:
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(HttpStatusCode statusCode) {
// Let 404 and 409 pass through without throwing
return statusCode != HttpStatus.NOT_FOUND
&& statusCode != HttpStatus.CONFLICT
&& super.hasError(statusCode);
}
});
return restTemplate;
}
Extending DefaultResponseErrorHandler means you only override the specific codes you want to handle gracefully, and everything else still throws normally. This is usually the right middle ground.
Solution 4: Migrate to WebClient
If you’re on Spring Boot 2.4+ or Spring 6, you’re probably looking at WebClient from Spring WebFlux. It handles error responses more elegantly with onStatus():
@Service
public class UserWebClientService {
private final WebClient webClient;
public UserWebClientService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
public Mono<User> findUser(String userId) {
return webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.onStatus(HttpStatus.NOT_FOUND::equals, response ->
Mono.empty()) // 404 → empty Mono, no exception
.onStatus(HttpStatusCode::is4xxClientError, response ->
response.bodyToMono(ErrorResponse.class)
.flatMap(err -> Mono.error(
new ServiceException("Client error: " + err.getMessage()))))
.onStatus(HttpStatusCode::is5xxServerError, response ->
Mono.error(new RetryableServiceException("Server error")))
.bodyToMono(User.class);
}
}
WebClient’s reactive pipeline makes the intent far clearer. Each onStatus() call is a discrete handler for a specific HTTP condition, and you can chain as many as you need. There’s no try-catch sprawl.
That said, WebClient requires your application to pull in spring-boot-starter-webflux. If you’re on a blocking MVC stack and don’t want to mix reactive code, sticking with RestTemplate and a custom error handler is perfectly fine.
Solution 5: Centralized Exception Handling with @ControllerAdvice
If many services in your application call external APIs via RestTemplate, duplicating try-catch blocks everywhere gets messy fast. Instead, let the exceptions propagate and handle them once at the web layer:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(HttpClientErrorException.class)
public ResponseEntity<ApiError> handleClientError(HttpClientErrorException ex) {
log.warn("External API client error: {} — {}", ex.getStatusCode(), ex.getResponseBodyAsString());
if (ex.getStatusCode() == HttpStatus.NOT_FOUND) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("EXTERNAL_NOT_FOUND", "The requested resource was not found."));
}
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(new ApiError("EXTERNAL_CLIENT_ERROR", "External service returned: " + ex.getStatusCode()));
}
@ExceptionHandler(HttpServerErrorException.class)
public ResponseEntity<ApiError> handleServerError(HttpServerErrorException ex) {
log.error("External API server error: {}", ex.getStatusCode(), ex);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(new ApiError("EXTERNAL_SERVER_ERROR", "External service is unavailable."));
}
@ExceptionHandler(ResourceAccessException.class)
public ResponseEntity<ApiError> handleConnectionError(ResourceAccessException ex) {
log.error("External API connection error", ex);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ApiError("CONNECTION_ERROR", "Could not connect to external service."));
}
}
This approach keeps your service layer clean and maps external API failures to sensible HTTP responses for your own API consumers. The tradeoff is less granular control per call site — use it for cross-cutting error handling, not as a replacement for business-logic-level error handling like the 404 → Optional.empty() pattern above.
Configuring Timeouts
One more thing that bites developers hard: by default, RestTemplate has no timeouts. If the remote server hangs, your thread hangs forever. Always configure timeouts explicitly:
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(3));
factory.setReadTimeout(Duration.ofSeconds(10));
return new RestTemplate(factory);
}
You’ll need spring-boot-starter-web and optionally httpclient5 on your classpath for HttpComponentsClientHttpRequestFactory. Without explicit timeouts, a slow downstream dependency can exhaust your thread pool under load — one of those production issues you only discover during a traffic spike.
When a timeout fires, you get a ResourceAccessException wrapping a SocketTimeoutException:
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "https://api.example.com/users/123":
Read timed out; nested exception is java.net.SocketTimeoutException: Read timed out
That ResourceAccessException is the same exception class as a connection refused error, so your catch block for network failures handles timeouts too.
Prevention Tips
Create dedicated RestTemplate beans — don’t use new RestTemplate() directly in your service. A Spring-managed bean lets you centralize timeout configuration, error handlers, interceptors, and base URLs.
Log the response body on errors — HttpStatusCodeException.getResponseBodyAsString() is your best friend for debugging 4xx/5xx failures. The remote API almost always puts the reason in the body.
Be explicit about which codes you expect — if a 409 Conflict is a valid business response in your domain, handle it explicitly. Treating all 4xx as “unexpected” leads to swallowed errors or over-broad exception handling.
Consider Resilience4j for retry and circuit-breaking — ResourceAccessException and HttpServerErrorException (5xx) are typically retryable. Resilience4j integrates cleanly with Spring Boot and handles exponential backoff automatically.
Summary
RestTemplate’s default behavior is to throw HttpClientErrorException for 4xx responses and HttpServerErrorException for 5xx. The right fix depends on your use case:
- Specific 404 / 409 handling? Catch
HttpClientErrorException.NotFoundorHttpClientErrorException.Conflictdirectly. - Want to inspect status codes without exceptions? Extend
DefaultResponseErrorHandlerto suppress the codes you care about, then useexchange(). - Greenfield service or Spring 6? Use WebClient with
onStatus()— it’s a cleaner API. - Cross-service error policy? Use
@ControllerAdviceas a safety net, but still handle business-logic cases at the call site.
Whatever pattern you choose, always configure explicit timeouts and always log the response body when a 4xx/5xx fires. Those two practices will save you hours of debugging.
Use Debugly’s stack trace formatter to quickly parse the nested RestClientException → HttpStatusCodeException → IOException chains that Spring produces and pinpoint the exact line in your code that triggered the error. And if you’re running into Spring Security 403 errors on top of REST client failures, check out the Spring Security 403 guide for a companion walkthrough.