When your application calls an external API, things will go wrong. The service might be down, the resource might not exist, or the server might just be having a bad day. How you handle those failures in your RestClient calls makes the difference between an app that crashes with cryptic 500 errors and one that responds gracefully with meaningful information. Let's walk through some best practices for error handling with Spring's RestClient, from the default behavior all the way through retry logic with Spring Framework 7's new resilience methods.
📦 Get the Code
Follow along with the complete working example.
Use the Default RestClient Builder
This is tip number one, and it's worth putting front and center. When you create a RestClient instance, you want to use the RestClient.Builder that Spring Boot auto-configures for you rather than creating one from scratch with RestClient.create().
Why does this matter? The default RestClient.Builder comes pre-configured with important things like observability. When you include Spring Boot Actuator or the new Spring Boot OpenTelemetry starter in Spring Boot 4, you get all of that baked-in observability for free. If you hand-roll your own instance, you lose all of it.
In a controller, that looks like this:
@RestController
public class HttpBinController {
private final RestClient client;
public HttpBinController(RestClient.Builder builder) {
this.client = builder
.baseUrl("https://httpbin.org")
.build();
}
}
Notice we're accepting the RestClient.Builder through constructor injection and then customizing it with our base URL. Spring Boot provides this builder automatically, so there's no additional configuration needed to get started.
For this tutorial, we're using httpbin.org, a simple HTTP request and response service that lets you test different status codes by calling endpoints like /status/404. It's perfect for testing error handling without having to stand up your own API.
Understanding the Default Error Behavior
Before we customize anything, it helps to understand what happens out of the box. The RestClient's default behavior is to throw an exception on any 4xx or 5xx response before your code even runs. So if you call an endpoint that returns a 404, your application will throw an exception and return a 500 Internal Server Error to the caller.
Here's a simple controller with two endpoints to demonstrate:
@GetMapping("/get")
public String get() {
return client.get()
.uri("/get")
.retrieve()
.body(String.class);
}
@GetMapping("/get/status/{code}")
public String getStatus(@PathVariable int code) {
return client.get()
.uri("/status/{code}", code)
.retrieve()
.body(String.class);
}
Calling /get works fine and returns a JSON response. But calling /get/status/404 returns a 500 error to the caller because the RestClient throws an exception when it receives that 404 from httpbin.
You can suppress this on a per-request basis using onStatus:
return client.get()
.uri("/status/{code}", code)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
// suppress the default exception
})
.body(String.class);
This works, but the moment you write a second method that also calls external APIs, you're duplicating that error handling logic everywhere. That's where centralized configuration comes in.
Centralize Your RestClient Configuration
Instead of building the RestClient inside your controller, move it into a @Configuration class. This way, every class that needs to talk to the same API can share the same RestClient instance with consistent error handling.
First, define the base URL in your application.yml:
httpbin:
base-url: https://httpbin.org
Then create the configuration class:
@Configuration
public class RestClientConfig {
@Value("${httpbin.base-url}")
private String baseUrl;
@Bean
public RestClient httpBinClient(RestClient.Builder builder) {
return builder
.baseUrl(baseUrl)
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
throw new NotFoundException("Resource not found: " + request.getURI());
}
throw new ApiException(
"Error calling " + request.getURI() + ": " + response.getStatusText(),
response.getStatusCode()
);
})
.build();
}
}
The defaultStatusHandler method lets you define error handling once at the RestClient level. Every request made through this client will go through this handler. In this example, we're throwing specific custom exceptions based on the status code: a NotFoundException for 404 responses and a general ApiException for everything else.
Your controller becomes much simpler now, accepting the RestClient directly:
@RestController
public class HttpBinController {
private final RestClient client;
public HttpBinController(RestClient client) {
this.client = client;
}
// ... endpoint methods
}
Since we defined the RestClient as a bean in our configuration, Spring will auto-wire it through constructor injection. If you're new to Spring, this is equivalent to annotating the constructor with @Autowired, but with a single constructor it's implicit.
Define Custom Exceptions
The custom exceptions we're throwing in the status handler are straightforward. Start with a base ApiException:
public class ApiException extends RuntimeException {
private final HttpStatusCode statusCode;
public ApiException(String message, HttpStatusCode statusCode) {
super(message);
this.statusCode = statusCode;
}
public HttpStatusCode getStatusCode() {
return statusCode;
}
}
Then create a more specific NotFoundException that extends it:
public class NotFoundException extends ApiException {
public NotFoundException(String message) {
super(message, HttpStatus.NOT_FOUND);
}
}
You can follow this pattern to create as many specific exception types as you need. Some teams create separate exceptions for 4xx client errors versus 5xx server errors. Others get more granular with UnauthorizedException, ForbiddenException, and so on. Choose the level of granularity that makes sense for your application.
Return Structured Errors with Problem Detail
Now that we're throwing custom exceptions, we need to catch them and return something useful to the caller. This is where a global exception handler and Problem Detail come together.
ProblemDetail is a representation of RFC 9457, which defines a standard format for HTTP API error responses. It was introduced in Spring Boot 3 and gives you a structured way to communicate errors that includes fields like type, title, status, detail, and instance. You can also add your own custom properties.
Create a global exception handler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(HttpStatusCodeException.class)
public ProblemDetail handle(HttpStatusCodeException ex) {
return ProblemDetail.forStatusAndDetail(
ex.getStatusCode(),
ex.getMessage()
);
}
}
The @RestControllerAdvice annotation combines @ControllerAdvice with @ResponseBody, meaning the return value of your exception handler methods gets serialized directly into the response body. The @ExceptionHandler annotation tells Spring to route any HttpStatusCodeException to this method.
Now when you call /get/status/404, instead of a generic 500 error, you get a well-structured JSON response:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Resource not found: https://httpbin.org/status/404",
"instance": "/get/status/404"
}
This is much more helpful for anyone consuming your API. They get the actual status code, a meaningful description, and context about what went wrong.
Add Retry Logic with Resilience Methods
Sometimes the right response to an error isn't to fail immediately. If an external service is temporarily unavailable, retrying the request after a short delay might succeed. Spring Framework 7 and Spring Boot 4 bring resilience methods from the Spring Retry project directly into the framework, making this easy to set up.
First, enable resilience methods in your application.properties or application.yml:
spring:
retry:
enabled: true
Then annotate your method with @Retryable:
private static final Logger log = LoggerFactory.getLogger(HttpBinController.class);
@Retryable(include = ApiException.class, maxAttempts = 3, delay = 1000, multiplier = 2)
@GetMapping("/get/unstable")
public String getUnstable() {
log.info("Attempting to get unstable resource");
return client.get()
.uri("/unstable")
.retrieve()
.body(String.class);
}
Let's break down the @Retryable configuration:
include = ApiException.classtells Spring to only retry when anApiException(or subclass) is thrown. You don't want to retry every exception. A 401 Unauthorized isn't going to succeed on the second try.maxAttempts = 3sets the maximum number of attempts.delay = 1000sets the initial delay to 1 second.multiplier = 2creates an exponential backoff. The first retry waits 1 second, the second waits 2 seconds, and the third waits 4 seconds.
Exponential backoff is important because you don't want to hammer a struggling service with rapid retries. Spacing them out gives the service time to recover.
When you call /get/unstable, you can see in the console logs that Spring retries the request multiple times before finally giving up:
Attempting to get unstable resource
Attempting to get unstable resource
Attempting to get unstable resource
Putting It All Together
Here's a summary of the practices we covered:
- Use the default RestClient.Builder that Spring Boot provides. You get observability and other pre-configured features for free.
- Centralize your RestClient configuration in a
@Configurationclass withdefaultStatusHandlerso error handling is consistent across all requests. - Create custom exceptions that carry meaningful status codes and messages.
- Use a global exception handler with
@RestControllerAdviceto catch exceptions and returnProblemDetailresponses. - Add retry logic with
@Retryablefor transient failures, using exponential backoff to be a good citizen to the services you depend on.
This isn't an exhaustive list of everything you can do. The Spring Framework 7 documentation has more information on error handling with the RestClient that goes beyond what we covered here. But these practices give you a solid foundation for building resilient API clients.
What are your go-to strategies for error handling with the RestClient? I'd love to hear what patterns have worked well for you. Happy Coding!
Related Articles
Spring Boot 3.x Features: Complete Guide to Major Updates (2022-2025)
Explore the transformative features introduced in Spring Boot 3.0 through 3.5, from Java 17 requirements and GraalVM native images to virtual threads and enhanced observability. This comprehensive guide covers each release's major improvements, including Docker Compose integration, Testcontainers support, structured logging, and SSL certificate management. Learn how Spring Boot 3.x...
How to Kill a Process Running on a Port (Windows, macOS & Linux)
Port 8080 already in use? Learn how to find and kill the process running on a port on Windows, macOS, and Linux—with copy-paste commands for each.
Building Custom Spring AI Advisors for Tool Logging and Token Tracking
Learn to build custom Spring AI advisors with the BaseAdvisor interface to log available tools, track tool invocations, and count token usage on every LLM call.