RestClient in Spring Boot 3: The Modern HTTP Client Guide
Learn how to use RestClient in Spring Boot 3.2+ with production insights, debugging tips, and real-world incident stories.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17 or later
- ✓Spring Boot 3.2 or later
- ✓Basic understanding of HTTP and REST APIs
- ✓Familiarity with Spring's dependency injection
- RestClient is the modern replacement for RestTemplate, introduced in Spring Framework 6.1 (Spring Boot 3.2).
- It offers a fluent, immutable API with better error handling and reactive integration.
- Use RestClient for synchronous HTTP calls; for async, prefer WebClient.
- Migrate from RestTemplate by wrapping it with RestClient.builder(restTemplate).
- Key features: interceptors, status handlers, and exchange filter functions.
Think of RestClient as a modern take on ordering a pizza. With RestTemplate, you had to call the pizza shop, wait on hold, and hope they got your order rightโit was messy and error-prone. RestClient is like using a sleek app: you fill in your order (the request), press send, and get a clear confirmation (the response) with options to handle wrong toppings (errors) gracefully. Itโs simpler, faster, and less likely to mess up.
If youโve been building Spring Boot applications for a while, youโve probably used RestTemplate. It was the go-to synchronous HTTP client for years. But letโs be honest: RestTemplate is a legacy beast. Itโs bloated, callback-heavy, and its error handling is a nightmare in production. Iโve lost count of how many times Iโve seen teams struggle with RestTemplateโs non-intuitive exception hierarchy or its tight coupling to the old Apache HttpClient.
Enter RestClient, introduced in Spring Framework 6.1 (Spring Boot 3.2). Itโs the HTTP client Spring should have had from the start. RestClient is a synchronous, fluent, and immutable client that borrows the best ideas from WebClient (reactive) but keeps things simple for blocking calls. Itโs not just a faceliftโitโs a complete rethinking of how we make HTTP requests in Spring.
In this guide, Iโll walk you through everything you need to know: from basic usage to advanced patterns, error handling, testing, and migration from RestTemplate. Iโll also share war stories from production where RestClient saved the dayโor where it could have if weโd used it sooner. By the end, youโll never want to touch RestTemplate again.
Why RestClient? The End of RestTemplate
RestTemplate has been deprecated since Spring 5.0, yet many teams still cling to it. I get itโmigration is painful. But RestClient is not just a new coat of paint; itโs a fundamentally better design. Hereโs why:
- Fluent API: RestClient uses a builder pattern thatโs intuitive. No more chaining
exchange()with anonymous classes. - Immutability: Once built, RestClient is thread-safe and immutable. No accidental state mutation.
- Better Error Handling: RestClient provides
onStatusanddefaultStatusHandlerto handle HTTP statuses cleanly. No more catchingHttpClientErrorExceptionand parsing the response body manually. - Reactive Integration: RestClient can be used alongside WebClient, sharing the same infrastructure.
- Testability: RestClient works seamlessly with MockRestServiceServer, making unit tests a breeze.
Letโs see a quick comparison. RestTemplate code to make a GET request and parse JSON:
``java // Old RestTemplate ResponseEntity<List<Payment>> response = restTemplate.exchange( "https://api.example.com/payments", HttpMethod.GET, null, new ParameterizedTypeReference<List<Payment>>() {} ); ``
Now with RestClient:
``java // New RestClient List<Payment> payments = restClient.get() .uri("https://api.example.com/payments") .retrieve() .body(new ParameterizedTypeReference<>() {}); ``
Cleaner, right? And thatโs just the beginning.
Building and Configuring RestClient
A well-configured RestClient is crucial for production. Hereโs how to set it up properly.
Basic Builder
``java RestClient restClient = ``RestClient.builder() .baseUrl("https://api.example.com") .defaultHeader("Authorization", "Bearer " + token) .defaultHeader("Accept", "application/json") .build();
Timeouts โ Never skip these. Use a custom ClientHttpRequestFactory like HttpComponentsClientHttpRequestFactory (Apache HttpClient) or JdkClientHttpRequestFactory (Java 11+).
```java import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(100); connectionManager.setDefaultMaxPerRoute(20);
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( HttpClients.custom() .setConnectionManager(connectionManager) .build() ); requestFactory.setConnectTimeout(5000); requestFactory.setReadTimeout(10000);
RestClient restClient = RestClient.builder() .requestFactory(requestFactory) .build(); ```
Error Handling โ Use onStatus for specific HTTP statuses and defaultStatusHandler for a catch-all.
``java restClient.get() .uri("/payments/{id}", paymentId) .retrieve() .onStatus(status -> ``status.value() == 404, (request, response) -> { throw new PaymentNotFoundException("Payment not found: " + paymentId); }) .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> { throw new ClientException(response.getStatusText()); }) .body(Payment.class);
Interceptors โ Great for logging, retries, or adding correlation IDs.
```java import org.springframework.http.client.ClientHttpRequestInterceptor;
ClientHttpRequestInterceptor loggingInterceptor = (request, body, execution) -> { System.out.println("Request: " + request.getMethod() + " " + request.getURI()); return execution.execute(request, body); };
RestClient restClient = RestClient.builder() .requestInterceptor(loggingInterceptor) .build(); ```
RestClient.builder().build() without customizing the request factory. In a high-throughput system, this leads to connection exhaustion. Always explicitly set a pooled connection manager.Making Requests: GET, POST, PUT, DELETE
RestClientโs fluent API makes all HTTP methods straightforward. Let's cover the common patterns.
GET โ Retrieve a resource.
``java Payment payment = restClient.get() .uri("/payments/{id}", paymentId) .retrieve() .body(Payment.class); ``
For a list with headers:
``java ResponseEntity<List<Payment>> response = restClient.get() .uri("/payments?status={status}", "completed") .retrieve() .toEntity(new ParameterizedTypeReference<>() {}); ``
POST โ Create a resource.
``java Payment newPayment = new Payment(amount, currency); Payment created = restClient.post() .uri("/payments") .body(newPayment) .retrieve() .body(Payment.class); ``
PUT โ Update a resource.
``java Payment updatedPayment = new Payment(amount, currency); restClient.put() .uri("/payments/{id}", paymentId) .body(updatedPayment) .retrieve() .toBodilessEntity(); ``
DELETE โ Remove a resource.
``java restClient.delete() .uri("/payments/{id}", paymentId) .retrieve() .toBodilessEntity(); ``
Exchange โ For full control over the response (e.g., to read headers).
``java String etag = restClient.get() .uri("/payments/{id}", paymentId) .exchange((request, response) -> { if (response.getStatusCode().is2xxSuccessful()) { return response.getHeaders().getE``Tag(); } else { throw new RuntimeException("Failed"); } });
Note: exchange gives you access to the raw ClientHttpResponse, but you must manage the response lifecycle (e.g., close the body).
exchange was used for every GET call. The developer forgot to close the response body, causing memory leaks. Prefer retrieve for most cases.exchange only when you need low-level access.Error Handling and Status Handlers
One of RestClientโs best features is its declarative error handling. You can handle specific status codes or ranges without messy try-catch blocks.
Basic Error Handling
By default, RestClient throws RestClientException for 4xx and 5xx responses. But you can customize this.
``java Payment payment = restClient.get() .uri("/payments/{id}", id) .retrieve() .onStatus(status -> ``status.value() == 404, (request, response) -> { throw new PaymentNotFoundException(); }) .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> { throw new ServiceUnavailableException(); }) .body(Payment.class);
Default Status Handler
You can set a default handler at the builder level:
``java RestClient restClient = ``RestClient.builder() .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { throw new CustomHttpException(response.getStatusCode(), response.getStatusText()); }) .build();
Reading Error Response Body
Sometimes you need the error body. Use onStatus with a custom extractor:
``java .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> { String errorBody = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8); throw new ClientException(response.getStatusCode(), errorBody); }) ``
Retry After
For 429 Too Many Requests, you can retry after a delay:
``java .onStatus(status -> ``status.value() == 429, (request, response) -> { String retryAfter = response.getHeaders().getFirst("Retry-After"); throw new RateLimitExceededException(retryAfter); })
Warning: Avoid using onStatus for every possible status code. Keep it maintainable by grouping statuses into ranges.
onStatus that logged the error but didn't throw. The result: null returned from body(), causing a confusing NPE downstream. Always throw or return a fallback.onStatus and defaultStatusHandler for clean, declarative error handling. Always throw a meaningful exception from handlers.Testing RestClient with MockRestServiceServer
Testing HTTP clients is critical. Spring provides MockRestServiceServer for mocking RestClient calls without hitting real endpoints.
Setup
```java import org.springframework.test.web.client.MockRestServiceServer; import static org.springframework.test.web.client.match.MockRestRequestMatchers.; import static org.springframework.test.web.client.response.MockRestResponseCreators.;
@ExtendWith(MockitoExtension.class) class PaymentServiceTest {
private MockRestServiceServer mockServer; private PaymentService paymentService;
@BeforeEach void setUp() { RestClient restClient = RestClient.builder() .baseUrl("https://api.example.com") .build(); mockServer = MockRestServiceServer.bindTo(restClient).build(); paymentService = new PaymentService(restClient); } ```
Test a Successful GET
```java @Test void shouldReturnPaymentWhenFound() { String json = "{\"id\":\"123\",\"amount\":100.0}"; mockServer.expect(requestTo("/payments/123")) .andExpect(method(HttpMethod.GET)) .andRespond(withSuccess(json, MediaType.APPLICATION_JSON));
Payment payment = paymentService.getPayment("123");
assertEquals("123", payment.id()); assertEquals(100.0, payment.amount()); } ```
Test Error Handling
```java @Test void shouldThrowNotFoundWhen404() { mockServer.expect(requestTo("/payments/999")) .andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThrows(PaymentNotFoundException.class, () -> { paymentService.getPayment("999"); }); } ```
Test Timeout
You can simulate timeouts with a custom response creator:
``java mockServer.expect(requestTo("/payments/123")) .andRespond(withSuccess().body("{\"id\":\"123\"}").throttle(10000)); // 10 sec delay ``
Important: MockRestServiceServer works with RestClientโs ClientHttpRequestFactory. If you use a custom factory, you may need to bind differently. See the docs for advanced scenarios.
What the Official Docs Won't Tell You
After using RestClient in production for over a year, here are the gotchas that the Spring docs gloss over.
1. RestClient.create() Uses JDK HttpClient with No Timeouts
RestClient.create() creates a client using JdkClientHttpRequestFactory (Java 11+). This factory has no default timeouts. In production, a downstream slow service will hang your threads indefinitely. Always use a custom factory with explicit timeouts.
2. onStatus Handlers Must Throw or Handle
If your onStatus handler doesn't throw an exception and doesn't consume the response body, RestClient will return null from or body()toEntity(). This leads to confusing NullPointerException. The official docs mention this, but itโs easy to miss. My rule: always throw a custom exception in onStatus.
3. MockRestServiceServer with Custom Request Factory
If you configure a custom ClientHttpRequestFactory (e.g., Apache HttpClient), MockRestServiceServer.bindTo(restClient) might not work because it uses the default SimpleClientHttpRequestFactory. You need to bind to the underlying ClientHttpRequestFactory or use bindTo(restClient).ignoreExpectOrder(true). See the test section for workarounds.
4. Exchange vs Retrieve: Memory Leak Risk
When using , you must close the response body. If you don't, the underlying HTTP connection may not be released. The exchange() method handles this automatically. Only use retrieve()exchange when you need low-level control, and always close the body in a finally block.
5. Default Headers Are Not Merged with Request Headers
If you set a default header (e.g., Authorization) in the builder, and then set the same header on a specific request, the request header overrides the default. This is consistent with HTTP semantics, but it surprises many developers. If you want to merge, you need to do it manually.
6. RestClient Is Not a Drop-in Replacement for RestTemplate
While you can wrap a RestTemplate with RestClient.builder(restTemplate), the resulting RestClient inherits RestTemplate's quirks, like its exception hierarchy. For a clean migration, build a new RestClient from scratch.
RestClient.create() and wondered why their service hung during a downstream outage. The fix: configure timeouts. The lesson: never trust defaults in production.Migrating from RestTemplate to RestClient
If you have an existing codebase with RestTemplate, you don't need to rewrite everything overnight. Hereโs a pragmatic migration strategy.
Step 1: Wrap RestTemplate
You can create a RestClient that delegates to an existing RestTemplate:
``java RestTemplate restTemplate = new ``RestTemplate(); RestClient restClient = RestClient.builder(restTemplate).build();
This gives you the fluent API while keeping the old RestTemplate configuration. But this is a stepping stoneโyou still have RestTemplate under the hood.
Step 2: Replace Calls Incrementally
Start with the most critical or simplest endpoints. Replace restTemplate.getForObject() with restClient.get().retrieve().body(). Keep the old code until youโve tested the new one.
Step 3: Migrate Error Handling
RestTemplate throws RestClientException subclasses like HttpClientErrorException. RestClient uses onStatus. You can create a compatibility layer:
``java restClient.get() .uri(url) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> { throw new HttpClientErrorException(response.getStatusCode(), response.getStatusText()); }) .body(String.class); ``
Step 4: Remove RestTemplate Eventually
Once all calls are migrated, remove the RestTemplate bean. This may break other code that depends on it, so do it in a separate release.
Common Pitfalls
- URI Template Variables: RestTemplate uses
UriTemplateHandler, RestClient usesuri(String, Object...). They are compatible, but check edge cases with encoded characters. - Exchange vs retrieve:
restTemplate.exchange()becomesrestClient.get().retrieve().toEntity(). For full control, use.exchange() - Interceptors: RestTemplateโs
ClientHttpRequestInterceptorworks with RestClient. You can reuse them.
When Not to Migrate
If your app is short-lived or RestTemplate usage is minimal, migration may not be worth it. But for long-term projects, RestClient is the future.
The 2 AM RestTemplate Timeout Outage
- Always set explicit timeouts on HTTP clients in production.
- Use RestClient's builder to configure timeouts, or wrap with a custom ClientHttpRequestFactory.
- Monitor thread pool exhaustionโitโs a silent killer.
- RestTemplateโs default error handling masks real issues; RestClient gives you clear status handlers.
- Migrate legacy RestTemplate code to RestClient proactively.
RestClient.builder().connectTimeout(Duration.ofSeconds(5)).build()RestClient.builder().requestFactory(new HttpComponentsClientHttpRequestFactory()).build()| File | Command / Code | Purpose |
|---|---|---|
| RestClientBasicExample.java | public class RestClientBasicExample { | Why RestClient? The End of RestTemplate |
| RestClientConfig.java | @Configuration | Building and Configuring RestClient |
| RestClientRequests.java | public class RestClientRequests { | Making Requests |
| RestClientErrorHandling.java | public class RestClientErrorHandling { | Error Handling and Status Handlers |
| PaymentServiceTest.java | class PaymentServiceTest { | Testing RestClient with MockRestServiceServer |
| RestClientGotchas.java | RestClient badClient = RestClient.create(); // No timeouts! | What the Official Docs Won't Tell You |
| MigrationExample.java | RestTemplate restTemplate = new RestTemplate(); | Migrating from RestTemplate to RestClient |
Key takeaways
Interview Questions on This Topic
Explain how RestClient handles HTTP status codes differently from RestTemplate.
onStatus method that allows you to handle specific status codes or ranges with lambda expressions. RestTemplate throws exceptions for all 4xx/5xx responses, requiring try-catch blocks. RestClient's approach is more readable and testable.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't