Calling External APIs with RestClient (Spring Boot 3.2+)
1. Overview
Starting from Spring Framework 6.1 (bundled with Spring Boot 3.2), Spring introduces RestClient. This is a synchronous (blocking) HTTP client that uses a fluent API similar to WebClient but doesn’t require Reactor. This article guides you through creating a RestClient, sending GET/POST requests, handling errors, configuring timeouts, adding interceptors, and writing tests. After reading, you’ll be able to replace RestTemplate with RestClient in new projects.
2. Installation
RestClient is located in the spring-web module. The web starter already includes this module and Jackson for JSON conversion:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Throughout this article, we’ll call a mock order API at https://api.shop.example. We’ll define our data using records:
public record Order(Long id, String customerName, BigDecimal total, String status) {}
public record CreateOrderRequest(String customerName, BigDecimal total) {}
The base URL is configured in application.properties:
shop.api.base-url=https://api.shop.example
3. Creating RestClient
3.1 The Quick Way
RestClient restClient = RestClient.create("https://api.shop.example");
This approach works well for testing or scripting. In a Spring Boot application, you should use the builder provided by Boot.
3.2 Using Spring Boot’s RestClient.Builder
Spring Boot automatically configures a RestClient.Builder bean. This builder already has all the HttpMessageConverters of your application pre-configured, such as your customized ObjectMapper. It also supports observability through Micrometer.
@Component
public class OrderClient {
private final RestClient restClient;
public OrderClient(RestClient.Builder builder,
@Value("${shop.api.base-url}") String baseUrl) {
this.restClient = builder
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
Explanation:
baseUrl(...): subsequent calls only need to pass relative paths like/orders/42.defaultHeader(...): this header is added to every request.build(): creates theRestClientinstance. This instance is thread-safe, so you create it once and reuse it.
Note: The
RestClient.Builderbean has prototype scope. Each time you inject it, you get a new builder. Therefore, configuration inOrderClientdoesn’t affect other clients. To apply common configuration to all builders, declare aRestClientCustomizerbean.
4. Sending GET Requests
4.1 Getting Response as an Object
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
get(): selects the HTTP method.uri("/orders/{id}", id): the{id}placeholder is automatically replaced and URL-encoded. The result isGET https://api.shop.example/orders/42.retrieve(): sends the request and prepares to read the response.body(Order.class): Jackson converts JSON to theOrderrecord.
4.2 Getting Status Code and Headers
When you need the status code or headers, use toEntity:
ResponseEntity<Order> response = restClient.get()
.uri("/orders/{id}", 42)
.retrieve()
.toEntity(Order.class);
System.out.println(response.getStatusCode());
System.out.println(response.getHeaders().getContentType());
System.out.println(response.getBody());
200 OK
application/json
Order[id=42, customerName=Lan, total=250000, status=PAID]
4.3 Getting Lists and Query Parameters
Generic types like List<Order> are erased at runtime. Therefore, you need ParameterizedTypeReference:
public List<Order> findOrders(String status, int page) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/orders")
.queryParam("status", status)
.queryParam("page", page)
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<Order>>() {});
}
Calling findOrders("PAID", 1) creates a request GET /orders?status=PAID&page=1. The base URL is preserved when you use uriBuilder.
5. Sending POST, PUT, DELETE Requests
public Order createOrder(CreateOrderRequest request) {
return restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(Order.class);
}
public void cancelOrder(Long id) {
restClient.delete()
.uri("/orders/{id}", id)
.retrieve()
.toBodilessEntity();
}
body(request): the object is serialized to JSON thanks to thecontentTypedeclared.toBodilessEntity(): use when you don’t need to read the body. This method still triggers the request and handles errors.
put() and patch() work exactly the same as post().
Note: If you call
retrieve()without following it withbody(...),toEntity(...), ortoBodilessEntity(), the request will not be sent. This is a common mistake with DELETE calls.
6. Error Handling
6.1 Default Behavior
By default, retrieve() throws an exception when the response has an error status:
- 4xx: throws
HttpClientErrorException, for exampleHttpClientErrorException.NotFound. - 5xx: throws
HttpServerErrorException.
Connection or timeout errors throw ResourceAccessException.
6.2 Customizing with onStatus
You usually want to convert HTTP errors to business exceptions:
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Order not found: " + id);
}
}
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new OrderNotFoundException(id);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new IllegalStateException(
"Shop API error: " + response.getStatusCode());
})
.body(Order.class);
}
Handlers are checked in the order they are declared. The first handler that matches the status will be executed. If no handler matches, the default behavior from section 6.1 is applied.
6.3 Full Control with exchange
When you need to read the error body or branch based on status, use exchange:
public Optional<Order> findOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.exchange((request, response) -> {
if (response.getStatusCode().value() == 404) {
return Optional.empty();
}
if (response.getStatusCode().isError()) {
throw new IllegalStateException("Error: " + response.getStatusCode());
}
return Optional.ofNullable(
response.bodyTo(Order.class));
});
}
Note: Inside
exchange, default status handlers are not applied. You must check the status yourself. If you forget, the error body might be parsed as anOrderwith null fields.
7. Timeouts and Underlying HTTP Client
By default, RestClient selects the HTTP library on the classpath in this order: Apache HttpClient 5, Jetty, Reactor Netty, JDK’s java.net.http.HttpClient, then SimpleClientHttpRequestFactory. You should always set a timeout. Otherwise, a slow API could hold your thread for a very long time.
@Configuration
public class ShopClientConfig {
@Bean
public RestClientCustomizer timeoutCustomizer() {
return builder -> {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(2));
factory.setReadTimeout(Duration.ofSeconds(5));
builder.requestFactory(factory);
};
}
}
RestClientCustomizer is applied to every RestClient.Builder that Spring Boot creates. If you only want to set a timeout for one client, call builder.requestFactory(factory) directly in that client’s constructor.
With a slow API, the request will fail after 5 seconds:
org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out
Starting with Spring Boot 3.4, you can also configure timeouts using properties spring.http.client.connect-timeout and spring.http.client.read-timeout.
8. Interceptors: Adding Tokens, Logging
ClientHttpRequestInterceptor lets you intercept every request. For example, the following code adds an API key and logs response time:
@Slf4j
public class ApiKeyInterceptor implements ClientHttpRequestInterceptor {
private final String apiKey;
public ApiKeyInterceptor(String apiKey) {
this.apiKey = apiKey;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().set("X-Api-Key", apiKey);
long start = System.currentTimeMillis();
ClientHttpResponse response = execution.execute(request, body);
log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getURI(),
response.getStatusCode(), System.currentTimeMillis() - start);
return response;
}
}
You register the interceptor when building the client:
this.restClient = builder
.baseUrl(baseUrl)
.requestInterceptor(new ApiKeyInterceptor(apiKey))
.build();
INFO ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)
Note: Don’t read
response.getBody()in the interceptor for logging. The stream can only be read once. If you must read it, wrap the request factory withBufferingClientHttpRequestFactory.
9. HTTP Interfaces: Declaring Clients with Interfaces
Instead of writing each call manually, you can describe an API using an interface. Spring will generate the implementation based on RestClient:
public interface OrderApi {
@GetExchange("/orders/{id}")
Order getOrder(@PathVariable Long id);
@PostExchange("/orders")
Order createOrder(@RequestBody CreateOrderRequest request);
}
@Configuration
public class OrderApiConfig {
@Bean
public OrderApi orderApi(RestClient.Builder builder,
@Value("${shop.api.base-url}") String baseUrl) {
RestClient restClient = builder.baseUrl(baseUrl).build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(OrderApi.class);
}
}
Now you just need to inject OrderApi and call orderApi.getOrder(42L). This approach is cleaner when an API has many endpoints. Timeouts, interceptors, and status handlers are still configured on the underlying RestClient.
10. Writing Tests with @RestClientTest
Starting with Spring Boot 3.2, @RestClientTest supports RestClient. This annotation only initializes beans related to the HTTP client. It also connects MockRestServiceServer to RestClient.Builder, so no real requests are sent outside.
Let’s test OrderClient with the version of getOrder that has onStatus from section 6.2:
@RestClientTest(components = OrderClient.class,
properties = "shop.api.base-url=https://api.shop.example")
class OrderClientTest {
@Autowired
private OrderClient orderClient;
@Autowired
private MockRestServiceServer server;
@Test
void givenExistingOrder_whenGetOrder_thenReturnsOrder() {
server.expect(requestTo("https://api.shop.example/orders/42"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"id":42,"customerName":"Lan","total":250000,"status":"PAID"}
""", MediaType.APPLICATION_JSON));
Order order = orderClient.getOrder(42L);
assertThat(order.id()).isEqualTo(42L);
assertThat(order.customerName()).isEqualTo("Lan");
assertThat(order.total()).isEqualByComparingTo("250000");
server.verify();
}
@Test
void givenMissingOrder_whenGetOrder_thenThrowsOrderNotFound() {
server.expect(requestTo("https://api.shop.example/orders/99"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> orderClient.getOrder(99L))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("Order not found: 99");
}
}
The static imports you need are in MockRestRequestMatchers and MockRestResponseCreators from packages org.springframework.test.web.client.match and org.springframework.test.web.client.response.
server.expect(...): declare the request you expect.andRespond(...): return a mock response for that request.server.verify(): verify that all declared requests were actually sent.
Note:
MockRestServiceServeronly works when the client is created fromRestClient.Builderthat Spring injects. If you callRestClient.create(...)inside a class, the test will send real requests to the network.
11. When Not to Use RestClient
- WebFlux (reactive) applications:
RestClientblocks threads. If called in a reactive pipeline, the event loop will be blocked. UseWebClientinstead. - Need to stream large data or Server-Sent Events without blocking:
WebClientis a better fit. - Legacy code runs fine with
RestTemplate: you don’t have to rewrite it.RestTemplateis still supported, just doesn’t receive new features. You can migrate gradually usingRestClient.create(restTemplate)to keep existing configuration.
If your application runs on Java 21 with virtual threads (spring.threads.virtual.enabled=true), the blocking model of RestClient has almost no scalability disadvantage. Code is still simple and easy to debug.
12. Conclusion
In this article, you’ve used RestClient to call external APIs with a fluent, easy-to-read API. We’ve covered: creating a client from Spring Boot’s builder, sending GET/POST requests, converting HTTP errors to business exceptions using onStatus or exchange, setting timeouts, adding interceptors, and declaring clients with HTTP interfaces. Finally, you’ve written tests with @RestClientTest and MockRestServiceServer without making real network calls.
For Spring Boot 3.2+ projects using the servlet model, RestClient should be your default choice over RestTemplate. Remember two things: always set a timeout, and always end the retrieve() chain with a method that reads the response.