So far, every request the product catalogue answered was served from its own code and data. Real applications also depend on APIs that someone else runs: a supplier's stock levels, a payment provider, an exchange-rate feed. That outgoing call is the least predictable line in the application. The other side can answer 404 or 500, refuse the connection, or accept it and never reply, and each of those reaches your code in a different way.
Spring Framework 6.1 added RestClient for these calls: a synchronous HTTP client with a fluent API. This article connects the catalogue to a supplier API with it. It covers how Spring Boot 4 wants the client created, GET and POST into records, exactly what is thrown for 4xx, 5xx and I/O failures and how to take control of it, and timeouts. The timeouts are measured rather than assumed, because with nothing configured, the client Boot 4.1.1 builds waits for as long as the server takes.
![]()
Everything below was produced on OpenJDK 21.0.6 with Spring Boot 4.1.1 (Spring Framework 7.0.9, Jackson 3.1.5, embedded Tomcat 11.0.24) and Gradle 9.7.1, in a project generated by Spring Initializr with dependencies=web,spring-restclient. The application ran from its jar on port 8123, against the stub API described below on port 8133. Every response, exception message, log line and duration is copied from those runs. Log lines have their timestamp prefix trimmed, and durations come from single runs on one machine, so treat them as indicative.
RestClient vs RestTemplate vs WebClient: which HTTP client?
Spring ships four ways to call an HTTP API. The first difference is the API you write against. RestTemplate and RestClient also share their plumbing, as the RestTemplate Javadoc points out: request factories, interceptors and message converters work the same way in both. WebClient has a reactive stack of its own.
| Client | Since | API style | Blocking | In this course |
|---|---|---|---|---|
RestTemplate | Framework 3.0 | template methods: getForObject, postForEntity, exchange | yes | not used |
RestClient | Framework 6.1 | fluent: get().uri(...).retrieve().body(...) | yes | this article |
WebClient | Framework 5.0 | fluent, returns Mono and Flux | no, reactive | Advanced course |
HTTP interface with @HttpExchange | Framework 6.0 | a Java interface whose implementation Spring generates on top of RestClient or WebClient | depends on the client below | Advanced course |
RestClient is the choice for an ordinary Spring MVC application: it blocks like the rest of the request thread, and its API reads in the order an HTTP request is built. WebClient pays off once the application itself is reactive, and HTTP interfaces are a declarative layer you add on top of RestClient when there are many endpoints to call.
Is RestTemplate deprecated? Not in the version this series uses. RestTemplate in spring-web 7.0.9 carries no @Deprecated annotation (javap -v finds no deprecation marker in the class), and its Javadoc only notes: "As of 6.1, RestClient offers a more modern API for synchronous HTTP access." The plan is public, though. In The state of HTTP clients in Spring (Brian Clozel, spring.io, 30 September 2025), the Spring team announced its intent to deprecate RestTemplate with Framework 7.0, to formally mark it deprecated in Framework 7.1 (November 2026, provisional), and to remove it in Framework 8.0, leaving open-source support for RestTemplate until at least 2029. The deprecation is tracked in spring-framework issue #36574, closed for milestone 7.1.0-M1. New code should use RestClient; existing RestTemplate code has time to move.
A local supplier API to call
The catalogue needs data from a supplier: supplier details, stock levels, a price list, and purchase orders it can create, change and cancel. So that every error and timeout below can be reproduced, the supplier is a single Java file built on the JDK's own com.sun.net.httpserver.HttpServer, with no dependencies:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
/**
* A fake supplier API for local experiments.
* Run: java SupplierApiStub.java [port] [stockDelaySeconds]
*/
public class SupplierApiStub {
private static final AtomicLong NEXT_ORDER_ID = new AtomicLong(5001);
private static int port;
private static int stockDelaySeconds;
public static void main(String[] args) throws IOException {
port = args.length > 0 ? Integer.parseInt(args[0]) : 8133;
stockDelaySeconds = args.length > 1 ? Integer.parseInt(args[1]) : 0;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", SupplierApiStub::handle);
server.start();
System.out.println("Supplier API stub on port " + port + ", stock delay " + stockDelaySeconds + "s");
}
private static void handle(HttpExchange exchange) throws IOException {
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getRawPath();
String query = exchange.getRequestURI().getRawQuery();
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
log(exchange, method, path, query, body);
if (method.equals("GET") && path.equals("/api/suppliers")) {
send(exchange, 200, """
[{"id":7,"name":"Hanoi Keyboards","country":"VN","leadTimeDays":5,"rating":4.8},\
{"id":8,"name":"Saigon Switches","country":"VN","leadTimeDays":12,"rating":4.1}]""");
} else if (method.equals("GET") && path.equals("/api/suppliers/search")) {
send(exchange, 200, "[]");
} else if (method.equals("GET") && path.equals("/api/suppliers/7")) {
exchange.getResponseHeaders().add("X-Rate-Limit-Remaining", "99");
send(exchange, 200, """
{"id":7,"name":"Hanoi Keyboards","country":"VN","leadTimeDays":5,"rating":4.8}""");
} else if (method.equals("GET") && path.startsWith("/api/suppliers/")) {
send(exchange, 404, """
{"code":"SUPPLIER_NOT_FOUND","message":"No supplier with id %s"}"""
.formatted(path.substring("/api/suppliers/".length())));
} else if (method.equals("POST") && path.equals("/api/purchase-orders")) {
if (body.contains("\"quantity\":0") || body.contains("\"quantity\":-")) {
send(exchange, 400, """
{"code":"INVALID_QUANTITY","message":"quantity must be positive"}""");
return;
}
long id = NEXT_ORDER_ID.getAndIncrement();
exchange.getResponseHeaders().add("Location", "http://localhost:" + port + "/api/purchase-orders/" + id);
send(exchange, 201, body.replaceFirst("\\{", "{\"id\":" + id + ",\"status\":\"PENDING\","));
} else if (method.equals("PUT") && path.startsWith("/api/purchase-orders/")) {
send(exchange, 200, body);
} else if (method.equals("DELETE") && path.startsWith("/api/purchase-orders/")) {
exchange.sendResponseHeaders(204, -1);
exchange.close();
} else if (method.equals("GET") && path.equals("/api/price-list")) {
send(exchange, 500, """
{"code":"INTERNAL","message":"price list is being rebuilt"}""");
} else if (method.equals("GET") && path.startsWith("/api/stock/")) {
sleep(stockDelaySeconds);
send(exchange, 200, "{\"sku\":\"" + path.substring("/api/stock/".length()) + "\",\"available\":42}");
} else {
send(exchange, 404, "{\"code\":\"NO_ROUTE\"}");
}
}
private static void log(HttpExchange exchange, String method, String path, String query, String body) {
StringBuilder line = new StringBuilder(method + " " + path + (query == null ? "" : "?" + query));
for (String name : new String[] {"Accept", "Content-Type", "X-Api-Key", "X-Request-Id", "User-Agent"}) {
String value = exchange.getRequestHeaders().getFirst(name);
if (value != null) {
line.append("\n ").append(name).append(": ").append(value);
}
}
if (query != null) {
for (String pair : query.split("&")) {
line.append("\n param: [").append(URLDecoder.decode(pair, StandardCharsets.UTF_8)).append("]");
}
}
if (!body.isEmpty()) {
line.append("\n body: ").append(body);
}
System.out.println(line);
}
private static void send(HttpExchange exchange, int status, String json) throws IOException {
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
private static void sleep(int seconds) {
try {
Thread.sleep(seconds * 1000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}The java launcher compiles and runs a single source file directly, so there is nothing to build. The two optional arguments are the port and the number of seconds the stock endpoint sleeps before it answers:
java SupplierApiStub.java 8133Supplier API stub on port 8133, stock delay 0s| Request | Response |
|---|---|
GET /api/suppliers | 200, two suppliers |
GET /api/suppliers/7 | 200, one supplier, plus the header X-Rate-Limit-Remaining: 99 |
GET /api/suppliers/{id}, other id | 404, {"code":"SUPPLIER_NOT_FOUND",...} |
GET /api/suppliers/search | 200, [] |
POST /api/purchase-orders | 201 with a Location header, or 400 when quantity is 0 or negative |
PUT /api/purchase-orders/{id} | 200, the request body echoed back |
DELETE /api/purchase-orders/{id} | 204, no body |
GET /api/price-list | 500, {"code":"INTERNAL",...} |
GET /api/stock/{sku} | 200 after sleeping for the second argument's number of seconds |
The stub prints every request with the headers that matter here and, for a query string, each parameter as it decodes it. That log is how this article shows what RestClient actually puts on the wire. Note the rating field in the supplier JSON: the catalogue has no use for it, which is normal for an API you do not control.
Adding spring-boot-starter-restclient
RestClient itself lives in spring-web, which spring-boot-starter-webmvc already brings, so RestClient.create() compiles in any web project. Spring Boot's support for it is a separate starter, spring-boot-starter-restclient, which Initializr adds for the spring-restclient dependency together with its test counterpart:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-restclient'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-restclient-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>./gradlew dependencies --configuration runtimeClasspath, trimmed to the branch the starter adds:
+--- org.springframework.boot:spring-boot-starter-restclient -> 4.1.1
| +--- org.springframework.boot:spring-boot-starter:4.1.1
| +--- org.springframework.boot:spring-boot-starter-jackson:4.1.1
| \--- org.springframework.boot:spring-boot-restclient:4.1.1
| +--- org.springframework.boot:spring-boot-http-converter:4.1.1
| +--- org.springframework.boot:spring-boot:4.1.1 (*)
| \--- org.springframework.boot:spring-boot-http-client:4.1.1Two of those modules do the work in this article. spring-boot-restclient contains RestClientAutoConfiguration, which defines the RestClient.Builder bean, and the RestClientCustomizer interface. spring-boot-http-client contains ClientHttpRequestFactoryBuilder, HttpClientSettings and the spring.http.clients.* properties that configure the underlying HTTP client.
Creating a RestClient: create(), builder() or the injected builder
Three static methods create a client without any help from Spring Boot:
RestClient plain = RestClient.create();
RestClient withBaseUrl = RestClient.create("http://localhost:8133");
RestClient configured = RestClient.builder()
.baseUrl("http://localhost:8133")
.build();They work, and RestClient.create() picked the JDK's java.net.http.HttpClient on its own. What they do not do is read anything from the Spring Boot application around them. In a Boot application, inject the RestClient.Builder that Boot auto-configures and build the client from it.
What does the auto-configured RestClient.Builder carry?
A client built from the injected builder and one from RestClient.create(), in the same application with the same properties, differed like this:
Built from the injected RestClient.Builder | RestClient.create() | |
|---|---|---|
| HTTP client | JdkClientHttpRequestFactory, created by Boot's ClientHttpRequestFactoryBuilder | JdkClientHttpRequestFactory, created by Spring Framework |
spring.http.clients.connect-timeout / read-timeout | applied | ignored: with read-timeout=3s set, a 10 s response still arrived |
| Redirects | followed (NORMAL); spring.http.clients.redirects=dont-follow switched it to NEVER | not followed (NEVER) |
| Message converters | ByteArray, String, Resource, AllEncompassingForm and JacksonJson converters | the same five |
| JSON mapper | Boot's jacksonJsonMapper bean, so spring.jackson.* applies | a JsonMapper of its own |
RestClientCustomizer beans | applied | not applied |
The JSON row is the one you notice first. Article 18 used spring.jackson.deserialization.fail-on-unknown-properties=true to reject request bodies with unknown fields. With that property set, a GET of supplier 7, whose JSON carries the extra rating field, into the Supplier record shown in the next section behaved differently depending on how the client was made. The client from the injected builder threw, shown here with its cause chain:
org.springframework.web.client.RestClientException: Error while extracting response for type [com.example.demo.supplier.Supplier] and content type [application/json]
caused by org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Unrecognized property "rating" (class com.example.demo.supplier.Supplier), not marked as ignorable
caused by tools.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized property "rating" (class com.example.demo.supplier.Supplier), not marked as ignorable (4 known properties: "id", "name", "country", "leadTimeDays")while the RestClient.create() client returned Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5], because its mapper never saw the property. Rejecting unknown fields from an upstream you do not control is rarely a good idea, since such APIs add fields over time; the point is that the injected builder follows the application's Jackson configuration and RestClient.create() does not.
Two more properties of the bean matter once you build clients from it. Boot registers restClientBuilder with prototype scope, so every injection point receives a fresh builder: two lookups in the same context returned two different DefaultRestClientBuilder instances, and a base URL set on one cannot leak into another client. And the builder is assembled by RestClientBuilderConfigurer, which applies the RestClientCustomizer beans; the only one in this application was Boot's own httpMessageConvertersRestClientCustomizer.
What happens if you inject RestClient.Builder without the starter?
The builder bean comes from spring-boot-starter-restclient, not from the web starter. In a project generated with dependencies=web alone, the configuration class from the next section stops the application:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of method supplierRestClient in com.example.demo.supplier.SupplierClientConfig required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found.
Action:
Consider defining a bean of type 'org.springframework.web.client.RestClient$Builder' in your configuration.The suggested action is misleading here. A hand-made RestClient.builder() bean would start, but without anything in the table above; the fix is the starter.
One RestClient bean per upstream API
The supplier integration gets its own feature package, com.example.demo.supplier. Its base URL and API key are configuration, so they go into a properties record, as in article 12:
package com.example.demo.supplier;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.supplier-api")
public record SupplierApiProperties(String baseUrl, String apiKey) {
}app.supplier-api.base-url=http://localhost:8133
app.supplier-api.api-key=demo-key-123A configuration class turns the injected builder into one RestClient bean for this upstream:
package com.example.demo.supplier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
@EnableConfigurationProperties(SupplierApiProperties.class)
public class SupplierClientConfig {
@Bean
RestClient supplierRestClient(RestClient.Builder builder, SupplierApiProperties properties) {
return builder
.baseUrl(properties.baseUrl())
.defaultHeader("X-Api-Key", properties.apiKey())
.build();
}
}baseUrl is put in front of every relative URI, and defaultHeader is added to every request: the stub printed X-Api-Key: demo-key-123 for each call made through this bean. A second upstream would get a second @Bean method built from its own builder, and with two RestClient beans in the context each injection point names the one it wants with @Qualifier.
Sending GET requests with RestClient
The supplier's JSON maps onto a record; how records bind was article 18's subject:
package com.example.demo.supplier;
public record Supplier(long id, String name, String country, int leadTimeDays) {
}All calls to the supplier go through one class, so the rest of the catalogue never deals with URLs or HTTP:
package com.example.demo.supplier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class SupplierClient {
private final RestClient restClient;
public SupplierClient(RestClient supplierRestClient) {
this.restClient = supplierRestClient;
}
public Supplier findSupplier(long id) {
return restClient.get()
.uri("/api/suppliers/{id}", id)
.retrieve()
.body(Supplier.class);
}
}The chain reads in the order of the request: get() picks the method, uri(...) fills the template /api/suppliers/{id} with the value, retrieve() hands the response to the default handling, and body(Supplier.class) converts the JSON into the record. supplierClient.findSupplier(7) returned:
Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5]and the stub printed:
GET /api/suppliers/7
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6Three details in those two outputs:
ratingwas ignored. Jackson 3 ignores unknown properties unless told otherwise.- No
Acceptheader was sent. The stub printsAcceptwhenever a request carries one, and acurlrequest to it showedAccept: */*; this request had none. If an upstream relies on content negotiation, add.accept(MediaType.APPLICATION_JSON)to the request. - Nothing is sent before
body().retrieve()only returns aResponseSpec. An interceptor that printed each outgoing request stayed silent for the full second betweenretrieve()returning andbody()being called, and printed the request insidebody().

The order of those steps explains most of what follows. Interceptors wrap the HTTP client, so they see every response, a 404 or 500 included, before any status handler decides to throw. Timeouts fire inside the HTTP client, where there is no status at all. And the body is converted last, only when no handler threw.
Reading a JSON array with ParameterizedTypeReference
List<Supplier>.class does not exist in Java, so a generic target type is passed as a ParameterizedTypeReference:
public List<Supplier> findSuppliers() {
return restClient.get()
.uri("/api/suppliers")
.retrieve()
.body(new ParameterizedTypeReference<List<Supplier>>() {});
}[Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5], Supplier[id=8, name=Saigon Switches, country=VN, leadTimeDays=12]]The trailing {} creates an anonymous subclass, and that subclass records the full type List<Supplier> where Jackson can read it. body(List.class) compiles too, but leaves Jackson without an element type: the first element came back as a java.util.LinkedHashMap, {id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5, rating=4.8}.
Reading the status and headers with toEntity
body() returns only the converted body. toEntity() returns the ResponseEntity from article 17, seen from the client side:
ResponseEntity<Supplier> entity = supplierRestClient.get()
.uri("/api/suppliers/{id}", 7)
.retrieve()
.toEntity(Supplier.class);
System.out.println("status = " + entity.getStatusCode());
System.out.println("content type = " + entity.getHeaders().getContentType());
System.out.println("rate limit = " + entity.getHeaders().getFirst("X-Rate-Limit-Remaining"));
System.out.println("body = " + entity.getBody());
System.out.println("headers = " + entity.getHeaders());status = 200 OK
content type = application/json
rate limit = 99
body = Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5]
headers = [content-length:"78", content-type:"application/json", date:"Sat, 12 Sep 2026 07:41:52 GMT", x-rate-limit-remaining:"99"]The header names are printed in lower case, which is how the JDK client stored them, but lookups ignore case: getFirst("X-Rate-Limit-Remaining") found 99.
Query parameters and URI encoding
The catalogue's supplier search has to send the text C++ & bàn phím ("C++ & keyboard") as one query parameter. Here are four ways to build that URI, each sent to the stub:
String q = "C++ & bàn phím";
// A: the value itself goes into queryParam(), build() gets no variables
supplierRestClient.get()
.uri(uriBuilder -> uriBuilder.path("/api/suppliers/search")
.queryParam("q", q)
.queryParam("country", "VN")
.build())
.retrieve()
.toBodilessEntity();
// B: queryParam() holds a URI variable, build() receives the value
supplierRestClient.get()
.uri(uriBuilder -> uriBuilder.path("/api/suppliers/search")
.queryParam("q", "{q}")
.queryParam("country", "VN")
.build(q))
.retrieve()
.toBodilessEntity();
// C: a URI template string with variables
supplierRestClient.get()
.uri("/api/suppliers/search?q={q}&country={country}", q, "VN")
.retrieve()
.toBodilessEntity();
// D: string concatenation
supplierRestClient.get()
.uri("/api/suppliers/search?q=" + q + "&country=VN")
.retrieve()
.toBodilessEntity();The stub received, in order A, B, C, D:
GET /api/suppliers/search?q=C++%20%26%20b%C3%A0n%20ph%C3%ADm&country=VN
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6
param: [q=C & bàn phím]
param: [country=VN]
GET /api/suppliers/search?q=C%2B%2B%20%26%20b%C3%A0n%20ph%C3%ADm&country=VN
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6
param: [q=C++ & bàn phím]
param: [country=VN]
GET /api/suppliers/search?q=C%2B%2B%20%26%20b%C3%A0n%20ph%C3%ADm&country=VN
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6
param: [q=C++ & bàn phím]
param: [country=VN]
GET /api/suppliers/search?q=C++%20&%20b%C3%A0n%20ph%C3%ADm&country=VN
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6
param: [q=C ]
param: [ bàn phím]
param: [country=VN]| Variant | + | space | & in the value | à, í | What the stub decoded |
|---|---|---|---|---|---|
A: queryParam("q", q) then build() | + | %20 | %26 | %C3%A0, %C3%AD | q=C & bàn phím: both + became spaces |
B: queryParam("q", "{q}"), build(q) | %2B | %20 | %26 | %C3%A0, %C3%AD | q=C++ & bàn phím |
C: uri("...?q={q}&country={country}") | %2B | %20 | %26 | %C3%A0, %C3%AD | q=C++ & bàn phím |
| D: string concatenation | + | %20 | & | %C3%A0, %C3%AD | three parameters: q=C , bàn phím and country=VN |
Two encoding rules produce that table. Text that is part of the URI template, including a literal value passed to queryParam, is encoded only where a character is not allowed in that part of the URI: a space, à, and inside a query parameter also &. A + is allowed there, so A left it alone, and + is also how HTML forms encode a space, which is how the stub's URLDecoder read it. Values passed as URI variables are encoded strictly, reserved characters included, so B and C sent %2B. Concatenation in D put the value into the template itself, where & is a separator. The same strict rule applies to path variables: uri("/api/stock/{sku}", "KB/87 ô") went out as /api/stock/KB%2F87%20%C3%B4, and the slash stayed inside one path segment.
The rule to keep: never build a URI by concatenation, and pass every value as a URI variable, either {name} in uri(String, Object...) or {name} in queryParam with the values given to build(...).
POST, PUT and DELETE with RestClient
A purchase order is sent as one record and comes back as another:
package com.example.demo.supplier;
public record PurchaseOrderRequest(long supplierId, String sku, int quantity) {
}package com.example.demo.supplier;
public record PurchaseOrder(long id, String status, long supplierId, String sku, int quantity) {
}Three more methods in SupplierClient, with the imports for MediaType and ResponseEntity:
public ResponseEntity<PurchaseOrder> placeOrder(PurchaseOrderRequest order) {
return restClient.post()
.uri("/api/purchase-orders")
.contentType(MediaType.APPLICATION_JSON)
.body(order)
.retrieve()
.toEntity(PurchaseOrder.class);
}
public PurchaseOrder updateOrder(PurchaseOrder order) {
return restClient.put()
.uri("/api/purchase-orders/{id}", order.id())
.contentType(MediaType.APPLICATION_JSON)
.body(order)
.retrieve()
.body(PurchaseOrder.class);
}
public ResponseEntity<Void> cancelOrder(long id) {
return restClient.delete()
.uri("/api/purchase-orders/{id}", id)
.retrieve()
.toBodilessEntity();
}Placing an order, raising its quantity and cancelling it:
ResponseEntity<PurchaseOrder> created = supplierClient.placeOrder(new PurchaseOrderRequest(7, "KB-87", 20));
System.out.println("status = " + created.getStatusCode());
System.out.println("location = " + created.getHeaders().getLocation());
System.out.println("body = " + created.getBody());
PurchaseOrder order = created.getBody();
PurchaseOrder updated = supplierClient.updateOrder(
new PurchaseOrder(order.id(), order.status(), order.supplierId(), order.sku(), 25));
System.out.println("updated = " + updated);
ResponseEntity<Void> cancelled = supplierClient.cancelOrder(order.id());
System.out.println("cancel = " + cancelled.getStatusCode() + ", hasBody=" + cancelled.hasBody() + ", body=" + cancelled.getBody());status = 201 CREATED
location = http://localhost:8133/api/purchase-orders/5001
body = PurchaseOrder[id=5001, status=PENDING, supplierId=7, sku=KB-87, quantity=20]
updated = PurchaseOrder[id=5001, status=PENDING, supplierId=7, sku=KB-87, quantity=25]
cancel = 204 NO_CONTENT, hasBody=false, body=nullWhat the stub received:
POST /api/purchase-orders
Content-Type: application/json
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6
body: {"supplierId":7,"sku":"KB-87","quantity":20}
PUT /api/purchase-orders/5001
Content-Type: application/json
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6
body: {"id":5001,"status":"PENDING","supplierId":7,"sku":"KB-87","quantity":25}
DELETE /api/purchase-orders/5001
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6body(order)goes through the same message converters as a response, in the other direction:JacksonJsonHttpMessageConverterwrote the record as JSON.contentType(MediaType.APPLICATION_JSON)says so explicitly, but a POST of a record without it still arrived withContent-Type: application/json, because the converter chose that type.toEntity(PurchaseOrder.class)is the way to read a 201: the status, theLocationheader throughgetHeaders().getLocation(), and the body together.toBodilessEntity()returns aResponseEntity<Void>with the status and headers and discards any body. Callingbody(String.class)on a 204 simply returnednull.
How RestClient handles 4xx and 5xx responses
With retrieve(), a 4xx or 5xx status becomes an exception before body() returns. The error body from the stub maps onto a small record:
package com.example.demo.supplier;
public record UpstreamError(String code, String message) {
}Asking for a supplier that does not exist, and printing what arrives:
try {
supplierClient.findSupplier(99);
}
catch (RestClientResponseException ex) {
System.out.println("class = " + ex.getClass().getName());
System.out.println("message = " + ex.getMessage());
System.out.println("status = " + ex.getStatusCode() + " / " + ex.getStatusText());
System.out.println("body string = " + ex.getResponseBodyAsString());
System.out.println("body as type = " + ex.getResponseBodyAs(UpstreamError.class));
System.out.println("content type = " + ex.getResponseHeaders().getContentType());
}class = org.springframework.web.client.HttpClientErrorException$NotFound
message = 404 Not Found: "{"code":"SUPPLIER_NOT_FOUND","message":"No supplier with id 99"}"
status = 404 NOT_FOUND / Not Found
body string = {"code":"SUPPLIER_NOT_FOUND","message":"No supplier with id 99"}
body as type = UpstreamError[code=SUPPLIER_NOT_FOUND, message=No supplier with id 99]
content type = application/jsonThe same code around a call to the price list, which answers 500:
public String priceList() {
return restClient.get()
.uri("/api/price-list")
.retrieve()
.body(String.class);
}class = org.springframework.web.client.HttpServerErrorException$InternalServerError
message = 500 Internal Server Error: "{"code":"INTERNAL","message":"price list is being rebuilt"}"
status = 500 INTERNAL_SERVER_ERROR / Internal Server Error
body string = {"code":"INTERNAL","message":"price list is being rebuilt"}
body as type = UpstreamError[code=INTERNAL, message=price list is being rebuilt]
content type = application/jsonAnd an order with quantity 0 produced HttpClientErrorException$BadRequest with the message 400 Bad Request: "{"code":"INVALID_QUANTITY","message":"quantity must be positive"}".
What to take from these:
- The message is the status, the reason phrase and the body in quotes. It contains neither the method nor the URL, so a log line with only
ex.getMessage()does not say which upstream call failed. - The body is already read into the exception.
getResponseBodyAsString()returns it as text andgetResponseBodyAs(UpstreamError.class)converts it into a type. - Every one of them is unchecked.
HttpClientErrorException.NotFoundextendsHttpClientErrorException, thenHttpStatusCodeException,RestClientResponseException,RestClientException,NestedRuntimeExceptionandRuntimeException. - Common statuses have their own subclass. spring-web 7.0.9 contains
BadRequest,Unauthorized,Forbidden,NotFound,MethodNotAllowed,NotAcceptable,Conflict,Gone,UnsupportedMediaType,UnprocessableEntity,UnprocessableContentandTooManyRequestsinsideHttpClientErrorException, andInternalServerError,NotImplemented,BadGateway,ServiceUnavailableandGatewayTimeoutinsideHttpServerErrorException.
Catching HttpClientErrorException.NotFound all over the catalogue would spread HTTP details into code that should not know about them. The next two tools move that decision into the client.
Throwing your own exception with onStatus
A missing supplier means something in the catalogue's own terms:
package com.example.demo.supplier;
public class SupplierNotFoundException extends RuntimeException {
public SupplierNotFoundException(long id) {
super("Supplier " + id + " does not exist");
}
}onStatus registers a handler for the statuses its predicate accepts, on this request only:
public Supplier findSupplier(long id) {
return restClient.get()
.uri("/api/suppliers/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new SupplierNotFoundException(id);
})
.body(Supplier.class);
}supplierClient.findSupplier(99) now throws:
com.example.demo.supplier.SupplierNotFoundException: Supplier 99 does not existThe predicate receives an HttpStatusCode, so HttpStatusCode::is4xxClientError, HttpStatusCode::is5xxServerError and HttpStatusCode::isError work as method references. The handler receives the request and the response, including its status, headers and body stream. Statuses the predicate does not accept still get the default exceptions.
The handler has to throw. One that only logged and returned let body(Supplier.class) go ahead with the 404's body, which failed with RestClientException: Error while extracting response for type [com.example.demo.supplier.Supplier] and content type [application/json], caused by MismatchedInputException: Cannot map `null` into type `long` .
One error policy for every call with defaultStatusHandler
For everything that is not a specific case, the policy belongs on the builder, so each call does not repeat it. Another exception carries the upstream status and body:
package com.example.demo.supplier;
import org.springframework.http.HttpStatusCode;
public class SupplierApiException extends RuntimeException {
private final HttpStatusCode upstreamStatus;
public SupplierApiException(HttpStatusCode upstreamStatus, String upstreamBody) {
super("Supplier API answered " + upstreamStatus.value() + ": " + upstreamBody);
this.upstreamStatus = upstreamStatus;
}
public HttpStatusCode getUpstreamStatus() {
return upstreamStatus;
}
}and defaultStatusHandler installs it for every request made through the bean, with imports for HttpStatusCode and StandardCharsets:
@Bean
RestClient supplierRestClient(RestClient.Builder builder, SupplierApiProperties properties) {
return builder
.baseUrl(properties.baseUrl())
.defaultHeader("X-Api-Key", properties.apiKey())
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
throw new SupplierApiException(response.getStatusCode(),
new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8));
})
.build();
}Three failing calls through the same client:
for (Runnable call : new Runnable[] {
() -> supplierClient.findSupplier(99),
() -> supplierClient.priceList(),
() -> supplierClient.placeOrder(new PurchaseOrderRequest(7, "KB-87", 0)) }) {
try {
call.run();
}
catch (Exception ex) {
System.out.println(ex.getClass().getName() + ": " + ex.getMessage());
}
}com.example.demo.supplier.SupplierNotFoundException: Supplier 99 does not exist
com.example.demo.supplier.SupplierApiException: Supplier API answered 500: {"code":"INTERNAL","message":"price list is being rebuilt"}
com.example.demo.supplier.SupplierApiException: Supplier API answered 400: {"code":"INVALID_QUANTITY","message":"quantity must be positive"}The first line shows the order in which handlers are consulted. HttpStatusCode::isError matches 404 as well, yet findSupplier still threw SupplierNotFoundException: the onStatus handlers of the request come first, then the builder's defaultStatusHandler, and only if neither matches does the built-in default throw HttpClientErrorException or HttpServerErrorException.
Handling the raw response with exchange
Sometimes a status is an answer rather than an error. For "find the supplier if it exists", a 404 should become Optional.empty(), not an exception. exchange hands you the raw response and skips the status handling entirely:
public Optional<Supplier> findSupplierOrEmpty(long id) {
return restClient.get()
.uri("/api/suppliers/{id}", id)
.exchange((request, response) -> {
if (response.getStatusCode().isSameCodeAs(HttpStatus.NOT_FOUND)) {
return Optional.empty();
}
if (response.getStatusCode().isError()) {
throw new SupplierApiException(response.getStatusCode(),
new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8));
}
return Optional.of(response.bodyTo(Supplier.class));
});
}7 -> Optional[Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5]]
99 -> Optional.emptyWith exchange, every status is yours. On a client configured with the defaultStatusHandler above, an exchange call to the price list returned its 500 status and body without throwing: neither the builder's defaultStatusHandler nor the built-in default runs there, and onStatus belongs to retrieve() only. response.bodyTo(UpstreamError.class) read that 500 body, and response.createException() built the exception retrieve() would have thrown: for supplier 99, HttpClientErrorException$NotFound with the message 404 Not Found: "{"code":"SUPPLIER_NOT_FOUND","message":"No supplier with id 99"}".
What happens when the connection is refused?
Nothing listens on port 8139. A client built from the injected builder with baseUrl("http://localhost:8139") failed after 1 ms with, shown with its cause chain:
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8139/api/suppliers/7": null
caused by java.net.ConnectException: null
caused by java.nio.channels.ClosedChannelException: nullThis is a different family. ResourceAccessException is a RestClientException but not a RestClientResponseException, because there is no response: no status, no headers, no body, and therefore no status handler runs. The message does include the method and the URL; the trailing null is the missing message of the JDK's ConnectException. An exchange call to the same address threw the same ResourceAccessException after 2 ms, because exchange only takes over once a response exists.

RestClient timeouts: connect timeout and read timeout
An outgoing call can wait in two places. The connect timeout limits how long establishing the TCP connection may take. The read timeout limits how long the client waits for the response once the request is on its way. Which HTTP client is in use decides both the defaults and the exceptions, so that comes first.
Which HTTP client does Spring Boot use?
The ClientHttpRequestFactoryBuilder and HttpClientSettings beans are what Boot uses to build the HTTP client for the injected builder. A throwaway runner in the supplier package prints them:
package com.example.demo.supplier;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class HttpClientReport {
@Bean
ApplicationRunner printHttpClient(ClientHttpRequestFactoryBuilder<?> requestFactoryBuilder,
HttpClientSettings httpClientSettings) {
return args -> {
System.out.println("factory builder = " + requestFactoryBuilder.getClass().getSimpleName());
System.out.println("request factory = " + requestFactoryBuilder.build(httpClientSettings).getClass().getName());
System.out.println("settings = " + httpClientSettings);
};
}
}factory builder = JdkClientHttpRequestFactoryBuilder
request factory = org.springframework.http.client.JdkClientHttpRequestFactory
settings = HttpClientSettings[cookieHandling=null, redirects=null, connectTimeout=null, readTimeout=null, sslBundle=null, inetAddressFilter=null]When no factory is configured, ClientHttpRequestFactoryBuilder.detect() checks the classpath in a fixed order: Apache HttpClient 5, then Jetty's client, then Reactor Netty, then the JDK's java.net.http.HttpClient, and only as a last resort SimpleClientHttpRequestFactory. The two starters bring none of the first three, so Boot 4.1.1 uses the JDK client, and every setting is null.
What are RestClient's default timeouts?
null settings suggest no timeouts, but that has to be measured. The stock endpoint gives a server that is slow to answer, and the address 10.255.255.1, where nothing replies on this network, gives a connection that is never accepted:
package com.example.demo.supplier;
public record StockLevel(String sku, int available) {
} public StockLevel stock(String sku) {
return restClient.get()
.uri("/api/stock/{sku}", sku)
.retrieve()
.body(StockLevel.class);
}To measure a call that may never return, it runs on a separate daemon thread, and the measuring code gives up after a fixed bound:
void timed(String label, int boundSeconds, Callable<?> call) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "timed-call");
t.setDaemon(true);
return t;
});
long start = System.nanoTime();
Future<?> future = executor.submit(call);
try {
Object result = future.get(boundSeconds, TimeUnit.SECONDS);
System.out.printf("%s: returned after %d ms: %s%n", label, (System.nanoTime() - start) / 1_000_000, result);
}
catch (TimeoutException ex) {
System.out.printf("%s: still waiting after %d ms, giving up%n", label, (System.nanoTime() - start) / 1_000_000);
}
catch (ExecutionException ex) {
Throwable cause = ex.getCause();
System.out.printf("%s: threw after %d ms%n", label, (System.nanoTime() - start) / 1_000_000);
System.out.println(cause.getClass().getName() + ": " + cause.getMessage());
for (Throwable c = cause.getCause(); c != null; c = c.getCause()) {
System.out.println(" caused by " + c.getClass().getName() + ": " + c.getMessage());
}
}
}With the stub started as java SupplierApiStub.java 8133 90, so that the stock endpoint sleeps for 90 seconds, a client from the injected builder was pointed first at the stub and then at http://10.255.255.1, with bounds of 60 and 100 seconds:
timed("plain builder client, GET /api/stock/KB-87", 60,
() -> stubClient.get().uri("/api/stock/{sku}", "KB-87").retrieve().body(StockLevel.class));
timed("plain builder client, GET http://10.255.255.1/api/stock/KB-87", 100,
() -> unroutableClient.get().uri("/api/stock/{sku}", "KB-87").retrieve().body(StockLevel.class));plain builder client, GET /api/stock/KB-87: still waiting after 60005 ms, giving up
plain builder client, GET http://10.255.255.1/api/stock/KB-87: still waiting after 100005 ms, giving upNeither call ended on its own. The JDK HttpClient behind the builder reported no connect timeout (connectTimeout() returned Optional.empty) and the request factory had no read timeout, and the operating system did not abort the connection attempt within 100 seconds either. In a web application, each such call holds a Tomcat request thread for as long as the upstream stays silent. Configure both timeouts for every upstream.
Setting timeouts with spring.http.clients
The spring.http.clients properties apply to every client built from the injected builder:
spring.http.clients.connect-timeout=2s
spring.http.clients.read-timeout=3sspring:
http:
clients:
connect-timeout: 2s
read-timeout: 3sThe values are Durations, with the suffixes from article 12. The singular names spring.http.client.connect-timeout and spring.http.client.read-timeout still appear in older examples; the 4.1.1 metadata marks them deprecated since 4.0.0, with the spring.http.clients.* names as replacements.
With the stub restarted with a 10 second delay and the two properties passed as -- arguments, the runner printed connectTimeout=PT2S, readTimeout=PT3S in its settings line, and the same two measurements now ended:
plain builder client, GET /api/stock/KB-87: threw after 3023 ms
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8133/api/stock/KB-87": Request cancelled
caused by java.net.http.HttpTimeoutException: Request cancelled
plain builder client, GET http://10.255.255.1/api/stock/KB-87: threw after 2004 ms
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://10.255.255.1/api/stock/KB-87": HTTP connect timed out
caused by java.net.http.HttpConnectTimeoutException: HTTP connect timed out
caused by java.net.ConnectException: HTTP connect timed outBoth timeouts fired on schedule, and both surfaced as ResourceAccessException, like the refused connection. In the same run, a RestClient.create() client calling the same endpoint was unaffected by the properties:
RestClient.create(), GET /api/stock/KB-87: returned after 10067 ms: StockLevel[sku=KB-87, available=42]Telling a connect timeout from a read timeout
The exception chain is where the difference shows, and it depends on the HTTP client:
JdkClientHttpRequestFactory (Boot's default) | SimpleClientHttpRequestFactory | |
|---|---|---|
| Connect timeout | cause java.net.http.HttpConnectTimeoutException: HTTP connect timed out | cause java.net.SocketTimeoutException: Connect timed out |
| Read timeout | cause java.net.http.HttpTimeoutException: Request cancelled | cause java.net.SocketTimeoutException: Read timed out |
| Measured | 2004 ms and 3023 ms | 2003 ms and 3022 ms |
With the JDK client the types differ, but HttpConnectTimeoutException extends HttpTimeoutException, so check for the subclass first. With the simple factory both are SocketTimeoutException and only the message tells them apart.

Per-client timeouts with HttpClientSettings
Global timeouts are a floor. A supplier known to be slow may need a longer read timeout than every other upstream, so the values move into the supplier's own properties, with defaults through @DefaultValue:
package com.example.demo.supplier;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties("app.supplier-api")
public record SupplierApiProperties(String baseUrl, String apiKey) {
public record SupplierApiProperties(
String baseUrl,
String apiKey,
@DefaultValue("2s") Duration connectTimeout,
@DefaultValue("5s") Duration readTimeout) {
}The configuration builds the request factory for this client itself, from the two auto-configured beans the runner printed. Imports: ClientHttpRequestFactoryBuilder and HttpClientSettings from org.springframework.boot.http.client, ClientHttpRequestFactory from org.springframework.http.client:
@Bean
RestClient supplierRestClient(RestClient.Builder builder, SupplierApiProperties properties) {
RestClient supplierRestClient(RestClient.Builder builder, SupplierApiProperties properties,
ClientHttpRequestFactoryBuilder<?> requestFactoryBuilder, HttpClientSettings httpClientSettings) {
ClientHttpRequestFactory requestFactory = requestFactoryBuilder
.build(httpClientSettings.withTimeouts(properties.connectTimeout(), properties.readTimeout()));
return builder
.baseUrl(properties.baseUrl())
.defaultHeader("X-Api-Key", properties.apiKey())
.requestFactory(requestFactory)
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
throw new SupplierApiException(response.getStatusCode(),
new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8));
})
.build();
}requestFactoryBuilderis whichever HTTP client Boot detected or was told to use, so this code followsspring.http.clients.imperative.factorybelow.httpClientSettings.withTimeouts(connect, read)returns a copy of the global settings with new timeouts and everything else kept. Withspring.http.clients.redirects=dont-followset globally, this client'sHttpClientreportedfollowRedirects=NEVERnext toconnectTimeout=PT2SandreadTimeout=PT5S.requestFactory(...)replaces the factory that Boot's configurer had put on the builder.HttpClientSettingsalso haswithConnectTimeout,withReadTimeoutand a staticdefaults()with every value unset.
With the global properties still at 2 s and 3 s and --app.supplier-api.connect-timeout=1s --app.supplier-api.read-timeout=5s, the supplier's own values won:
supplierClient.stock(KB-87): threw after 5013 ms
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8133/api/stock/KB-87": Request cancelled
caused by java.net.http.HttpTimeoutException: Request cancelled
supplierRestClient.mutate() to 10.255.255.1: threw after 1002 ms
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://10.255.255.1/api/stock/KB-87": HTTP connect timed out
caused by java.net.http.HttpConnectTimeoutException: HTTP connect timed out
caused by java.net.ConnectException: HTTP connect timed outThe second line also shows that mutate() copies the request factory: a client derived with supplierRestClient.mutate().baseUrl("http://10.255.255.1").build() kept the 1 s connect timeout.
Outside the injected beans, ClientHttpRequestFactoryBuilder has static methods for each client: jdk(), httpComponents(), jetty(), reactor() and simple(). ClientHttpRequestFactoryBuilder.jdk().build(HttpClientSettings.defaults().withTimeouts(Duration.ofSeconds(2), Duration.ofSeconds(4))) gave a factory whose read timeout fired after 4003 ms. It always builds the JDK client and ignores every spring.http.clients property, which makes it the choice only when that is what you want.
Switching the HTTP client with spring.http.clients.imperative.factory
spring.http.clients.imperative.factory overrides detection. Its type is the enum ImperativeHttpClientsProperties.Factory, with the constants HTTP_COMPONENTS, JETTY, REACTOR, JDK and SIMPLE:
spring.http.clients.imperative.factory=simplespring:
http:
clients:
imperative:
factory: simplefactory builder = SimpleClientHttpRequestFactoryBuilder
request factory = org.springframework.boot.http.client.SimpleClientHttpRequestFactoryBuilder$SimpleClientHttpsRequestFactory
settings = HttpClientSettings[cookieHandling=null, redirects=null, connectTimeout=PT2S, readTimeout=PT3S, sslBundle=null, inetAddressFilter=null]The lower-case simple bound, and so did the upper-case HTTP_COMPONENTS. With the same 2 s and 3 s timeouts, the simple factory produced the SocketTimeoutException chains from the table above, after 2003 ms and 3022 ms. Naming a client whose library is not on the classpath is not caught when the property binds, but when the clientHttpRequestFactoryBuilder bean is created, which stopped the application. The end of the cause chain for HTTP_COMPONENTS:
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder]: Factory method 'clientHttpRequestFactoryBuilder' threw exception with message: org/apache/hc/client5/http/classic/HttpClient
... 85 common frames omitted
Caused by: java.lang.NoClassDefFoundError: org/apache/hc/client5/http/classic/HttpClient
... 88 common frames omitted
Caused by: java.lang.ClassNotFoundException: org.apache.hc.client5.http.classic.HttpClient
... 94 common frames omittedOnce Apache HttpClient 5 is on the classpath, detection picks it first anyway, since it heads the order above. Whatever the client, the code that inspects timeout exceptions has to match it; the handler at the end of this article checks the JDK client's types.
Logging outgoing requests with a ClientHttpRequestInterceptor
A ClientHttpRequestInterceptor sits between RestClient and the HTTP client, exactly where the pipeline diagram puts it. This one adds a request id header and logs the method, URI, status and duration through SLF4J, as set up in article 14:
package com.example.demo.supplier;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
public class SupplierApiLoggingInterceptor implements ClientHttpRequestInterceptor {
private static final Logger log = LoggerFactory.getLogger(SupplierApiLoggingInterceptor.class);
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
String requestId = UUID.randomUUID().toString();
request.getHeaders().set("X-Request-Id", requestId);
long start = System.nanoTime();
try {
ClientHttpResponse response = execution.execute(request, body);
log.info("{} {} -> {} in {} ms [{}]", request.getMethod(), request.getURI(),
response.getStatusCode().value(), elapsedMillis(start), requestId);
return response;
}
catch (IOException ex) {
log.warn("{} {} failed after {} ms [{}]: {}", request.getMethod(), request.getURI(),
elapsedMillis(start), requestId, ex.toString());
throw ex;
}
}
private static long elapsedMillis(long start) {
return (System.nanoTime() - start) / 1_000_000;
}
}It is registered on the builder:
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
throw new SupplierApiException(response.getStatusCode(),
new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8));
})
.requestInterceptor(new SupplierApiLoggingInterceptor())
.build();Calling supplier 7, supplier 99 and the price list, placing an order, and calling port 8139, where nothing listens, through a client derived with mutate() logged the following, with the DEBUG lines from the next section left out:
INFO 38589 --- [demo] [ main] c.e.d.s.SupplierApiLoggingInterceptor : GET http://localhost:8133/api/suppliers/7 -> 200 in 23 ms [772e8377-7220-45e4-b8a5-ce28207db4a6]
INFO 38589 --- [demo] [ main] c.e.d.s.SupplierApiLoggingInterceptor : GET http://localhost:8133/api/suppliers/99 -> 404 in 1 ms [5c6c0d15-5cae-468e-9216-41c680c1fdd7]
INFO 38589 --- [demo] [ main] c.e.d.s.SupplierApiLoggingInterceptor : GET http://localhost:8133/api/price-list -> 500 in 0 ms [5ce0e643-0a62-4d89-8afd-3442d38fc6fb]
INFO 38589 --- [demo] [ main] c.e.d.s.SupplierApiLoggingInterceptor : POST http://localhost:8133/api/purchase-orders -> 201 in 7 ms [2c606114-c282-48c0-bfbb-da3300747e07]
WARN 38589 --- [demo] [ main] c.e.d.s.SupplierApiLoggingInterceptor : GET http://localhost:8139/api/suppliers/7 failed after 1 ms [b0574b9b-fd11-4100-9ca8-28d028fbf655]: java.net.ConnectExceptionand the stub saw the header, for example:
GET /api/suppliers/7
X-Api-Key: demo-key-123
X-Request-Id: 772e8377-7220-45e4-b8a5-ce28207db4a6
User-Agent: Java-http-client/21.0.6The 404 and the 500 were logged as ordinary responses. The interceptor returned them, and only afterwards did onStatus and defaultStatusHandler turn them into SupplierNotFoundException and SupplierApiException. I/O failures arrive in the interceptor as the IOException thrown by execution.execute, which is why the refused connection took the catch branch; a read timeout does too, and was logged during the 504 run later in this article as failed after 3007 ms [...]: java.net.http.HttpTimeoutException: Request cancelled. The interceptor rethrows, so RestClient still wraps the exception in ResourceAccessException.
What does logging.level.org.springframework.web.client print?
Spring's own logging for RestClient is much quieter than the interceptor:
logging.level.org.springframework.web.client=DEBUGFor the same five calls, DEBUG added exactly three lines:
DEBUG 38589 --- [demo] [ main] o.s.web.client.DefaultRestClient : Reading to [com.example.demo.supplier.Supplier]
DEBUG 38589 --- [demo] [ main] o.s.web.client.DefaultRestClient : Writing [PurchaseOrderRequest[supplierId=7, sku=KB-87, quantity=20]] as "application/json" with org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
DEBUG 38589 --- [demo] [ main] o.s.web.client.DefaultRestClient : Reading to [com.example.demo.supplier.PurchaseOrder]There is no method, URL, status or duration, and nothing at all for the 404, the 500 or the refused connection, because no body was converted for them. TRACE on org.springframework.web.client and org.springframework.http printed the same three lines. The Writing line is worth knowing about for another reason: it prints the request object's toString(), so a record carrying a password or token would end up in the log. DEBUG helps with converter selection; the interceptor is what shows traffic.
Turning upstream failures into 502, 503 and 504
The catalogue's own API should not answer 500 because a supplier had a bad day. Each kind of upstream failure has a matching gateway status: 502 Bad Gateway when the upstream answered with an error, 504 Gateway Timeout when it did not answer in time, and 503 Service Unavailable when it could not be reached. Article 20 covers @RestControllerAdvice and ProblemDetail; here is only the handler:
package com.example.demo.supplier;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpTimeoutException;
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.ResourceAccessException;
@RestControllerAdvice
public class UpstreamFailureHandler {
@ExceptionHandler(SupplierNotFoundException.class)
ProblemDetail supplierNotFound(SupplierNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(SupplierApiException.class)
ProblemDetail supplierApiFailed(SupplierApiException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_GATEWAY,
"Supplier API answered " + ex.getUpstreamStatus().value());
}
@ExceptionHandler(ResourceAccessException.class)
ProblemDetail upstreamUnreachable(ResourceAccessException ex) {
Throwable cause = ex.getCause();
if (cause instanceof HttpTimeoutException && !(cause instanceof HttpConnectTimeoutException)) {
return ProblemDetail.forStatusAndDetail(HttpStatus.GATEWAY_TIMEOUT, "Upstream did not answer in time");
}
return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, "Upstream is unreachable");
}
}The product side exposes the supplier data from the product package:
package com.example.demo.product;
import com.example.demo.supplier.StockLevel;
import com.example.demo.supplier.Supplier;
import com.example.demo.supplier.SupplierClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductSupplierController {
private final SupplierClient supplierClient;
public ProductSupplierController(SupplierClient supplierClient) {
this.supplierClient = supplierClient;
}
@GetMapping("/{sku}/stock")
public StockLevel stock(@PathVariable String sku) {
return supplierClient.stock(sku);
}
@GetMapping("/suppliers/{id}")
public Supplier supplier(@PathVariable long id) {
return supplierClient.findSupplier(id);
}
@GetMapping("/price-list")
public String priceList() {
return supplierClient.priceList();
}
}The stub ran with a 10 second stock delay, and the application with a 3 second read timeout for the supplier:
java SupplierApiStub.java 8133 10java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8123 --app.supplier-api.read-timeout=3sThe price list, whose upstream answers 500:
curl -i localhost:8123/api/products/price-listHTTP/1.1 502
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 03:05:40 GMT
{"detail":"Supplier API answered 500","instance":"/api/products/price-list","status":502,"title":"Bad Gateway"}Stock levels, whose upstream sleeps longer than the read timeout. The response came after 3.01 s:
curl -i localhost:8123/api/products/KB-87/stockHTTP/1.1 504
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 03:05:43 GMT
{"detail":"Upstream did not answer in time","instance":"/api/products/KB-87/stock","status":504,"title":"Gateway Timeout"}A supplier, after stopping the stub:
curl -i localhost:8123/api/products/suppliers/7HTTP/1.1 503
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 03:05:44 GMT
Connection: close
{"detail":"Upstream is unreachable","instance":"/api/products/suppliers/7","status":503,"title":"Service Unavailable"}Before the stub was stopped, /api/products/suppliers/99 answered 404 with {"detail":"Supplier 99 does not exist","instance":"/api/products/suppliers/99","status":404,"title":"Not Found"}. With the application restarted on --app.supplier-api.base-url=http://10.255.255.1, /api/products/KB-87/stock answered the same 503 after 2.10 s, the default 2 s connect timeout. Three things to know about this handler. ResourceAccessException comes from every RestClient in the application, so the handler speaks for all upstreams, which is why its messages say "Upstream" rather than "Supplier API". Its timeout check matches the JDK client's exception types, as noted where the HTTP client was switched. And it is an advice of its own: article 20 showed that Spring consults advices from the lowest @Order value up and the first one with any matching handler answers, so next to a GlobalExceptionHandler with a catch-all Exception handler, give this class an explicit @Order ahead of it or move these methods into that class.
RestClient failures and how to handle them
Every row below was produced in this article, with the JDK client Boot selects by default:
| Failure | Default exception | How to handle it |
|---|---|---|
| 4xx response | HttpClientErrorException subclass such as NotFound; message 404 Not Found: "..." with the body | onStatus for statuses with a domain meaning; getResponseBodyAs(...) to read the error body |
| 5xx response | HttpServerErrorException subclass such as InternalServerError | defaultStatusHandler on the builder; answer 502 |
| Status that is a normal outcome, such as 404 | the same HttpClientErrorException with retrieve() | exchange, returning Optional.empty() or a default |
| Connection refused | ResourceAccessException, cause ConnectException: null | answer 503 |
| Connect timeout | ResourceAccessException, cause HttpConnectTimeoutException: HTTP connect timed out | spring.http.clients.connect-timeout or per client; answer 503 |
| Read timeout | ResourceAccessException, cause HttpTimeoutException: Request cancelled | spring.http.clients.read-timeout or per client; answer 504 |
| No timeout configured, upstream silent | none: the call was still waiting after 60 s | always set both timeouts |
| Body does not fit the target type | RestClientException: Error while extracting response for type [...] | fix the DTO or the Jackson settings |
| Status handler returns without throwing | the error body is converted into the success type, and that usually fails | always throw from a status handler |
A few related topics are deliberately left for later. Retrying failed calls and opening a circuit breaker when an upstream keeps failing belong to the Advanced course. Testing SupplierClient without a running stub, with MockRestServiceServer and @RestClientTest, is part of Chapter 6. An upstream that needs a custom trust store over HTTPS uses an SSL bundle, selected globally with spring.http.clients.ssl.bundle or per client through Boot's RestClientSsl bean.
FAQ
Is RestTemplate deprecated in Spring Boot 4?
Not in Spring Boot 4.1.1: RestTemplate in Spring Framework 7.0.9 has no @Deprecated annotation. The Spring team plans to deprecate it formally in Framework 7.1, provisionally November 2026, and to remove it in Framework 8.0, as announced on the spring.io blog in September 2025 and tracked in spring-framework issue #36574. Write new code with RestClient.
What is the default timeout of RestClient in Spring Boot?
There is none. With nothing configured, Boot 4.1.1 builds the JDK HttpClient without a connect timeout and without a read timeout: a call to a server that did not answer was still waiting after 60 seconds, and a connection attempt to an unreachable address was still pending after 100 seconds. Set spring.http.clients.connect-timeout and spring.http.clients.read-timeout, or give a client its own values through HttpClientSettings.
Why can't Spring Boot find a RestClient.Builder bean?
Because spring-boot-starter-restclient is missing. spring-boot-starter-webmvc brings the RestClient class but not the auto-configuration that defines the builder bean, so startup fails with required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found. Add the starter rather than defining a builder bean yourself.
How do I set a timeout for one RestClient only?
Inject ClientHttpRequestFactoryBuilder<?> and HttpClientSettings, build a factory with requestFactoryBuilder.build(httpClientSettings.withTimeouts(connect, read)), and pass it to requestFactory(...) on the builder. The values override spring.http.clients.* for that client only, the other global settings are kept, and clients derived with mutate() inherit them.
Does RestClient throw an exception for a 404?
Yes, with retrieve(): HttpClientErrorException.NotFound, whose message is 404 Not Found: followed by the response body in quotes. Register onStatus to throw your own exception instead, or use exchange when a 404 is a normal result that should become something like Optional.empty().
When should I use exchange instead of retrieve?
When a status is an expected outcome rather than an error, or when one decision needs the status, headers and body together. No status handler runs inside exchange, not even the builder's defaultStatusHandler, so your function has to handle every status itself; response.createException() gives you the default exception when you want it. Network failures still throw ResourceAccessException, exactly as with retrieve().
Conclusion
RestClient is Spring's synchronous HTTP client for new code, and in Spring Boot it should come from the injected RestClient.Builder of spring-boot-starter-restclient, which carries Boot's Jackson mapper, the spring.http.clients settings and the customizers, and gives each bean its own builder. One RestClient bean per upstream sets the base URL and default headers, and a small client class keeps HTTP out of the rest of the code. GET, POST, PUT and DELETE convert records in both directions, toEntity adds the status and headers, and URI variables are the only safe way to put values into a URI. Errors come in two families: statuses, which you shape with onStatus, defaultStatusHandler or exchange, and I/O failures, which always arrive as ResourceAccessException. Timeouts are not set unless you set them, so every upstream needs both. An interceptor makes the traffic visible, and a short handler turns all of it into 502, 503 and 504 for the catalogue's own clients.
The next article is optional and goes in a different direction: server-side rendering with Thymeleaf, for readers who want to build traditional web pages rather than JSON APIs.