Đến giờ, mọi request mà product catalogue trả lời đều được phục vụ từ code và dữ liệu của chính nó. Application thật còn phụ thuộc vào những API do người khác vận hành: tồn kho của nhà cung cấp, cổng thanh toán, nguồn tỷ giá. Lời gọi đi ra ngoài đó là dòng code khó đoán nhất trong application. Phía bên kia có thể trả 404 hoặc 500, từ chối kết nối, hoặc nhận kết nối rồi không bao giờ trả lời, và mỗi trường hợp đến với code của bạn theo một cách khác nhau.
Spring Framework 6.1 thêm RestClient cho những lời gọi này: một HTTP client đồng bộ với API kiểu fluent. Bài này dùng nó để nối catalogue với API của một nhà cung cấp. Nội dung gồm: Spring Boot 4 muốn client được tạo ra như thế nào, GET và POST với record, chính xác exception nào được ném cho 4xx, 5xx và lỗi I/O cùng cách giành lại quyền kiểm soát, và timeout. Timeout được đo thực tế chứ không phỏng đoán, vì khi không cấu hình gì, client mà Boot 4.1.1 tạo ra sẽ chờ lâu bao nhiêu tùy server.
![]()
Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Jackson 3.1.5, embedded Tomcat 11.0.24) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web,spring-restclient. Application chạy từ file jar ở port 8123, gọi tới API stub mô tả bên dưới ở port 8133. Mọi response, message của exception, dòng log và thời gian đo đều được copy từ các lần chạy đó. Các dòng log đã được cắt phần timestamp ở đầu, và thời gian đo lấy từ một lần chạy trên một máy, nên chỉ mang tính tham khảo.
RestClient, RestTemplate hay WebClient: chọn HTTP client nào?
Spring có bốn cách để gọi một HTTP API. Khác biệt đầu tiên là API mà bạn viết. RestTemplate và RestClient còn dùng chung phần hạ tầng, như Javadoc của RestTemplate có ghi: request factory, interceptor và message converter hoạt động giống nhau ở cả hai. WebClient có một stack reactive riêng.
| Client | Có từ | Kiểu API | Blocking | Trong khóa học này |
|---|---|---|---|---|
RestTemplate | Framework 3.0 | template method: getForObject, postForEntity, exchange | có | không dùng |
RestClient | Framework 6.1 | fluent: get().uri(...).retrieve().body(...) | có | bài này |
WebClient | Framework 5.0 | fluent, trả về Mono và Flux | không, reactive | khóa Advanced |
HTTP interface với @HttpExchange | Framework 6.0 | một Java interface, Spring tự sinh phần implementation dựa trên RestClient hoặc WebClient | tùy client bên dưới | khóa Advanced |
RestClient là lựa chọn cho một application Spring MVC thông thường: nó blocking giống phần còn lại của request thread, và API của nó đọc theo đúng thứ tự một HTTP request được dựng lên. WebClient chỉ đáng dùng khi bản thân application đã là reactive, còn HTTP interface là một lớp khai báo đặt lên trên RestClient khi có nhiều endpoint cần gọi.
RestTemplate có bị deprecated không? Chưa, ở phiên bản series này dùng. RestTemplate trong spring-web 7.0.9 không có annotation @Deprecated (javap -v không tìm thấy dấu deprecation nào trong class), và Javadoc của nó chỉ ghi: "As of 6.1, RestClient offers a more modern API for synchronous HTTP access." Tuy vậy, kế hoạch đã được công bố. Trong bài The state of HTTP clients in Spring (Brian Clozel, spring.io, 30/9/2025), đội Spring thông báo ý định deprecate RestTemplate từ Framework 7.0, chính thức đánh dấu deprecated ở Framework 7.1 (tháng 11/2026, dự kiến), và xóa hẳn ở Framework 8.0, nghĩa là RestTemplate vẫn được hỗ trợ open-source ít nhất đến năm 2029. Việc deprecate được theo dõi ở spring-framework issue #36574, đã đóng cho milestone 7.1.0-M1. Code mới nên dùng RestClient; code RestTemplate sẵn có vẫn còn thời gian để chuyển.
Một API nhà cung cấp chạy local để gọi thử
Catalogue cần dữ liệu từ một nhà cung cấp: thông tin nhà cung cấp, tồn kho, bảng giá, và các đơn đặt hàng mà nó có thể tạo, sửa và hủy. Để mọi lỗi và timeout bên dưới đều tái hiện được, nhà cung cấp này là một file Java duy nhất dựng trên com.sun.net.httpserver.HttpServer có sẵn trong JDK, không cần dependency nào:
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();
}
}
}Launcher java compile và chạy thẳng một file source duy nhất, nên không cần build gì. Hai argument tùy chọn là port và số giây endpoint tồn kho ngủ trước khi trả lời:
java SupplierApiStub.java 8133Supplier API stub on port 8133, stock delay 0s| Request | Response |
|---|---|
GET /api/suppliers | 200, hai nhà cung cấp |
GET /api/suppliers/7 | 200, một nhà cung cấp, kèm header X-Rate-Limit-Remaining: 99 |
GET /api/suppliers/{id}, id khác | 404, {"code":"SUPPLIER_NOT_FOUND",...} |
GET /api/suppliers/search | 200, [] |
POST /api/purchase-orders | 201 kèm header Location, hoặc 400 khi quantity bằng 0 hay âm |
PUT /api/purchase-orders/{id} | 200, trả lại đúng body của request |
DELETE /api/purchase-orders/{id} | 204, không có body |
GET /api/price-list | 500, {"code":"INTERNAL",...} |
GET /api/stock/{sku} | 200 sau khi ngủ số giây bằng argument thứ hai |
Stub in ra mọi request cùng những header quan trọng ở đây, và với query string thì in từng parameter sau khi decode. Log đó là cách bài này cho thấy RestClient thực sự gửi gì lên đường truyền. Để ý field rating trong JSON của nhà cung cấp: catalogue không cần tới nó, chuyện rất bình thường với một API bạn không kiểm soát.
Thêm spring-boot-starter-restclient
Bản thân RestClient nằm trong spring-web, thứ mà spring-boot-starter-webmvc đã kéo vào, nên RestClient.create() compile được trong mọi web project. Phần hỗ trợ của Spring Boot cho nó là một starter riêng, spring-boot-starter-restclient, được Initializr thêm vào cho dependency spring-restclient cùng với bản dành cho test:
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, cắt gọn chỉ còn nhánh do starter thêm vào:
+--- 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.1Hai module trong đó gánh phần việc chính của bài này. spring-boot-restclient chứa RestClientAutoConfiguration, nơi định nghĩa bean RestClient.Builder, và interface RestClientCustomizer. spring-boot-http-client chứa ClientHttpRequestFactoryBuilder, HttpClientSettings và các property spring.http.clients.* dùng để cấu hình HTTP client bên dưới.
Tạo RestClient: create(), builder() hay builder được inject
Ba static method tạo client mà không cần Spring Boot giúp:
RestClient plain = RestClient.create();
RestClient withBaseUrl = RestClient.create("http://localhost:8133");
RestClient configured = RestClient.builder()
.baseUrl("http://localhost:8133")
.build();Chúng chạy được, và RestClient.create() tự chọn java.net.http.HttpClient của JDK. Điều chúng không làm là đọc bất cứ thứ gì từ application Spring Boot bao quanh. Trong một application Boot, hãy inject RestClient.Builder mà Boot auto-configure và dựng client từ đó.
RestClient.Builder do Boot auto-configure mang theo những gì?
Một client dựng từ builder được inject và một client từ RestClient.create(), trong cùng application với cùng property, khác nhau như sau:
Dựng từ RestClient.Builder được inject | RestClient.create() | |
|---|---|---|
| HTTP client | JdkClientHttpRequestFactory, do ClientHttpRequestFactoryBuilder của Boot tạo | JdkClientHttpRequestFactory, do Spring Framework tạo |
spring.http.clients.connect-timeout / read-timeout | được áp dụng | bị bỏ qua: đã đặt read-timeout=3s mà response sau 10 s vẫn về |
| Redirect | đi theo (NORMAL); spring.http.clients.redirects=dont-follow đổi thành NEVER | không đi theo (NEVER) |
| Message converter | các converter ByteArray, String, Resource, AllEncompassingForm và JacksonJson | đúng năm converter đó |
| JSON mapper | bean jacksonJsonMapper của Boot, nên spring.jackson.* có tác dụng | một JsonMapper riêng |
Bean RestClientCustomizer | được áp dụng | không được áp dụng |
Dòng JSON là dòng dễ nhận ra nhất. Bài 18 dùng spring.jackson.deserialization.fail-on-unknown-properties=true để từ chối request body có field lạ. Khi đặt property đó, lệnh GET nhà cung cấp 7, với JSON có thêm field rating, vào record Supplier ở phần tiếp theo cho kết quả khác nhau tùy cách tạo client. Client từ builder được inject ném exception, kèm chuỗi cause:
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")trong khi client RestClient.create() trả về Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5], vì mapper của nó không hề biết tới property này. Từ chối field lạ từ một upstream bạn không kiểm soát hiếm khi là ý hay, vì những API như vậy thêm field theo thời gian; điều cần thấy ở đây là builder được inject đi theo cấu hình Jackson của application, còn RestClient.create() thì không.
Hai đặc điểm nữa của bean này quan trọng khi bạn dựng client từ nó. Boot đăng ký restClientBuilder với scope prototype, nên mỗi chỗ inject nhận một builder mới: hai lần lấy bean trong cùng context trả về hai instance DefaultRestClientBuilder khác nhau, và base URL đặt trên builder này không thể lọt sang client khác. Ngoài ra builder được lắp ráp bởi RestClientBuilderConfigurer, class áp dụng các bean RestClientCustomizer; trong application này chỉ có một bean như vậy, chính là httpMessageConvertersRestClientCustomizer của Boot.
Chuyện gì xảy ra nếu inject RestClient.Builder mà thiếu starter?
Bean builder đến từ spring-boot-starter-restclient, không phải từ web starter. Trong project sinh chỉ với dependencies=web, class cấu hình ở phần tiếp theo làm application dừng lại:
***************************
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.Gợi ý trong phần Action ở đây dễ gây hiểu nhầm. Một bean RestClient.builder() tự tạo sẽ khởi động được, nhưng thiếu mọi thứ trong bảng ở trên; cách sửa đúng là thêm starter.
Mỗi API upstream một RestClient bean
Phần tích hợp với nhà cung cấp có package riêng theo tính năng, com.example.demo.supplier. Base URL và API key của nó là config, nên chúng vào một properties record, giống bài 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-123Một class cấu hình biến builder được inject thành một bean RestClient cho upstream này:
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 được ghép vào trước mọi URI tương đối, còn defaultHeader được thêm vào mọi request: stub in ra X-Api-Key: demo-key-123 với mỗi lời gọi qua bean này. Upstream thứ hai sẽ có @Bean method thứ hai dựng từ builder riêng của nó, và khi context có hai bean RestClient, mỗi chỗ inject chỉ rõ bean mình cần bằng @Qualifier.
Gửi request GET với RestClient
JSON của nhà cung cấp map vào một record; record được bind ra sao là chủ đề của bài 18:
package com.example.demo.supplier;
public record Supplier(long id, String name, String country, int leadTimeDays) {
}Mọi lời gọi tới nhà cung cấp đi qua một class, để phần còn lại của catalogue không phải đụng tới URL hay 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);
}
}Chuỗi method đọc theo đúng thứ tự của request: get() chọn method, uri(...) điền giá trị vào template /api/suppliers/{id}, retrieve() giao response cho cơ chế xử lý mặc định, và body(Supplier.class) chuyển JSON thành record. supplierClient.findSupplier(7) trả về:
Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5]và stub in ra:
GET /api/suppliers/7
X-Api-Key: demo-key-123
User-Agent: Java-http-client/21.0.6Ba chi tiết trong hai output đó:
ratingbị bỏ qua. Jackson 3 bỏ qua property lạ trừ khi được cấu hình khác.- Không có header
Acceptnào được gửi. Stub inAcceptbất cứ khi nào request có header này, và một requestcurltới nó hiệnAccept: */*; request này thì không có. Nếu upstream dựa vào content negotiation, thêm.accept(MediaType.APPLICATION_JSON)vào request. - Chưa có gì được gửi đi trước
body().retrieve()chỉ trả về mộtResponseSpec. Một interceptor in ra từng request đi ra đã im lặng suốt một giây giữa lúcretrieve()trả về và lúcbody()được gọi, rồi mới in request bên trongbody().

Thứ tự các bước giải thích phần lớn những gì phía sau. Interceptor bọc quanh HTTP client, nên nó thấy mọi response, kể cả 404 hay 500, trước khi bất kỳ status handler nào quyết định ném exception. Timeout xảy ra bên trong HTTP client, nơi chưa hề có status. Còn body được chuyển đổi sau cùng, chỉ khi không handler nào ném exception.
Đọc JSON array bằng ParameterizedTypeReference
Java không có List<Supplier>.class, nên type generic được truyền qua một 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]]Cặp {} ở cuối tạo ra một anonymous subclass, và subclass đó ghi lại type đầy đủ List<Supplier> ở chỗ Jackson đọc được. body(List.class) cũng compile được, nhưng Jackson không biết type của phần tử: phần tử đầu tiên trả về là một java.util.LinkedHashMap, {id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5, rating=4.8}.
Đọc status code và header bằng toEntity
body() chỉ trả về body đã chuyển đổi. toEntity() trả về ResponseEntity của bài 17, nhìn từ phía client:
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"]Tên header được in ra dạng chữ thường, đúng như cách client của JDK lưu, nhưng việc tra cứu không phân biệt hoa thường: getFirst("X-Rate-Limit-Remaining") vẫn tìm ra 99.
Query parameter và cách encode URI
Tính năng tìm nhà cung cấp của catalogue phải gửi chuỗi C++ & bàn phím dưới dạng một query parameter duy nhất. Dưới đây là bốn cách dựng URI đó, cách nào cũng được gửi thử tới 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();Stub nhận được, theo thứ tự 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]| Cách | + | dấu cách | & trong giá trị | à, í | Stub decode ra |
|---|---|---|---|---|---|
A: queryParam("q", q) rồi build() | + | %20 | %26 | %C3%A0, %C3%AD | q=C & bàn phím: cả hai dấu + thành dấu cách |
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: nối chuỗi | + | %20 | & | %C3%A0, %C3%AD | ba parameter: q=C , bàn phím và country=VN |
Hai quy tắc encode tạo ra bảng trên. Phần chữ thuộc về URI template, kể cả một giá trị literal truyền vào queryParam, chỉ bị encode ở những ký tự không được phép trong phần đó của URI: dấu cách, à, và riêng trong query parameter thì cả &. Dấu + được phép ở đó nên A giữ nguyên, mà + cũng là cách HTML form encode dấu cách, nên URLDecoder của stub đọc nó thành dấu cách. Giá trị truyền dưới dạng URI variable thì bị encode chặt, kể cả các ký tự dành riêng, nên B và C gửi %2B. Cách nối chuỗi ở D đưa giá trị vào chính template, nơi & là dấu phân cách. Quy tắc chặt đó cũng áp dụng cho path variable: uri("/api/stock/{sku}", "KB/87 ô") được gửi đi thành /api/stock/KB%2F87%20%C3%B4, và dấu gạch chéo vẫn nằm trong một path segment.
Quy tắc cần nhớ: đừng bao giờ dựng URI bằng cách nối chuỗi, và truyền mọi giá trị dưới dạng URI variable, hoặc {name} trong uri(String, Object...), hoặc {name} trong queryParam với giá trị truyền cho build(...).
POST, PUT và DELETE với RestClient
Một đơn đặt hàng được gửi đi dưới dạng một record và quay về dưới dạng một record khác:
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) {
}Thêm ba method vào SupplierClient, cùng import cho MediaType và 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();
}Đặt một đơn, tăng số lượng rồi hủy nó:
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=nullNhững gì stub nhận được:
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)đi qua đúng các message converter dùng cho response, theo chiều ngược lại:JacksonJsonHttpMessageConverterghi record thành JSON.contentType(MediaType.APPLICATION_JSON)nói rõ điều đó, nhưng một POST gửi record mà không có dòng này vẫn tới nơi vớiContent-Type: application/json, vì converter đã tự chọn type đó.toEntity(PurchaseOrder.class)là cách đọc một response 201: status, headerLocationquagetHeaders().getLocation()và body trong cùng một lần.toBodilessEntity()trả vềResponseEntity<Void>gồm status và header, bỏ qua mọi body. Gọibody(String.class)trên một response 204 đơn giản là trả vềnull.
RestClient xử lý response 4xx và 5xx thế nào?
Với retrieve(), status 4xx hoặc 5xx trở thành exception trước khi body() kịp trả về. Body lỗi từ stub map vào một record nhỏ:
package com.example.demo.supplier;
public record UpstreamError(String code, String message) {
}Hỏi một nhà cung cấp không tồn tại và in ra những gì nhận được:
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/jsonCùng đoạn code đó bọc quanh lời gọi bảng giá, endpoint trả 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/jsonCòn một đơn có quantity bằng 0 tạo ra HttpClientErrorException$BadRequest với message 400 Bad Request: "{"code":"INVALID_QUANTITY","message":"quantity must be positive"}".
Những điều rút ra:
- Message gồm status code, reason phrase và body đặt trong ngoặc kép. Nó không có method lẫn URL, nên một dòng log chỉ có
ex.getMessage()sẽ không cho biết lời gọi upstream nào thất bại. - Body đã được đọc sẵn vào exception.
getResponseBodyAsString()trả về dạng text, còngetResponseBodyAs(UpstreamError.class)chuyển nó thành một type. - Tất cả đều là unchecked exception.
HttpClientErrorException.NotFoundkế thừaHttpClientErrorException, rồi tớiHttpStatusCodeException,RestClientResponseException,RestClientException,NestedRuntimeExceptionvàRuntimeException. - Các status phổ biến có subclass riêng. spring-web 7.0.9 có
BadRequest,Unauthorized,Forbidden,NotFound,MethodNotAllowed,NotAcceptable,Conflict,Gone,UnsupportedMediaType,UnprocessableEntity,UnprocessableContentvàTooManyRequestsbên trongHttpClientErrorException, cùngInternalServerError,NotImplemented,BadGateway,ServiceUnavailablevàGatewayTimeoutbên trongHttpServerErrorException.
Bắt HttpClientErrorException.NotFound rải rác khắp catalogue sẽ kéo chi tiết HTTP vào những chỗ không nên biết về nó. Hai công cụ tiếp theo dời quyết định đó vào trong client.
Ném exception của riêng bạn với onStatus
Việc không có nhà cung cấp mang ý nghĩa riêng trong ngôn ngữ của catalogue:
package com.example.demo.supplier;
public class SupplierNotFoundException extends RuntimeException {
public SupplierNotFoundException(long id) {
super("Supplier " + id + " does not exist");
}
}onStatus đăng ký một handler cho những status mà predicate chấp nhận, chỉ trên request này:
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);
}Giờ supplierClient.findSupplier(99) ném ra:
com.example.demo.supplier.SupplierNotFoundException: Supplier 99 does not existPredicate nhận một HttpStatusCode, nên HttpStatusCode::is4xxClientError, HttpStatusCode::is5xxServerError và HttpStatusCode::isError dùng được như method reference. Handler nhận request và response, gồm status, header và body stream. Những status mà predicate không chấp nhận vẫn nhận exception mặc định.
Handler bắt buộc phải ném exception. Một handler chỉ log rồi return đã để body(Supplier.class) tiếp tục với body của response 404, và thất bại với RestClientException: Error while extracting response for type [com.example.demo.supplier.Supplier] and content type [application/json], cause là MismatchedInputException: Cannot map `null` into type `long` .
Một chính sách lỗi cho mọi lời gọi với defaultStatusHandler
Với mọi trường hợp không đặc biệt, chính sách xử lý nên nằm trên builder để từng lời gọi không phải lặp lại. Một exception khác mang theo status và body của upstream:
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;
}
}và defaultStatusHandler cài nó cho mọi request đi qua bean, với import cho HttpStatusCode và 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();
}Ba lời gọi lỗi qua cùng một 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"}Dòng đầu cho thấy thứ tự các handler được xét. HttpStatusCode::isError cũng khớp với 404, vậy mà findSupplier vẫn ném SupplierNotFoundException: các handler onStatus của request được xét trước, rồi tới defaultStatusHandler của builder, và chỉ khi không cái nào khớp thì cơ chế mặc định mới ném HttpClientErrorException hoặc HttpServerErrorException.
Tự xử lý response thô với exchange
Có lúc status là một câu trả lời chứ không phải lỗi. Với thao tác "tìm nhà cung cấp nếu có", một 404 nên trở thành Optional.empty() chứ không phải exception. exchange đưa cho bạn response thô và bỏ qua hoàn toàn phần xử lý status:
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.emptyVới exchange, mọi status là việc của bạn. Trên một client đã cấu hình defaultStatusHandler ở trên, lời gọi exchange tới bảng giá trả về status 500 và body mà không ném exception: cả defaultStatusHandler của builder lẫn cơ chế mặc định đều không chạy ở đây, còn onStatus chỉ thuộc về retrieve(). response.bodyTo(UpstreamError.class) đọc được body 500 đó, và response.createException() tạo ra đúng exception mà retrieve() lẽ ra đã ném: với nhà cung cấp 99 là HttpClientErrorException$NotFound với message 404 Not Found: "{"code":"SUPPLIER_NOT_FOUND","message":"No supplier with id 99"}".
Chuyện gì xảy ra khi kết nối bị từ chối?
Không có gì lắng nghe ở port 8139. Một client dựng từ builder được inject với baseUrl("http://localhost:8139") thất bại sau 1 ms, kèm chuỗi cause:
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: nullĐây là một họ exception khác. ResourceAccessException là một RestClientException nhưng không phải RestClientResponseException, vì không có response nào: không status, không header, không body, nên cũng không status handler nào chạy. Message có method và URL; chữ null ở cuối là message bị thiếu của ConnectException trong JDK. Một lời gọi exchange tới cùng địa chỉ ném đúng ResourceAccessException đó sau 2 ms, vì exchange chỉ tiếp quản khi đã có response.

Timeout trong RestClient: connect timeout và read timeout
Một lời gọi đi ra có thể phải chờ ở hai chỗ. Connect timeout giới hạn thời gian để thiết lập kết nối TCP. Read timeout giới hạn thời gian client chờ response sau khi request đã được gửi đi. HTTP client đang dùng quyết định cả giá trị mặc định lẫn exception, nên phải biết client nào trước đã.
Spring Boot dùng HTTP client nào?
Bean ClientHttpRequestFactoryBuilder và HttpClientSettings là thứ Boot dùng để dựng HTTP client cho builder được inject. Một runner dùng tạm trong package supplier in chúng ra:
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]Khi không cấu hình factory nào, ClientHttpRequestFactoryBuilder.detect() kiểm tra classpath theo một thứ tự cố định: Apache HttpClient 5, rồi client của Jetty, rồi Reactor Netty, rồi java.net.http.HttpClient của JDK, và cuối cùng mới tới SimpleClientHttpRequestFactory. Hai starter không kéo theo thư viện nào trong ba cái đầu, nên Boot 4.1.1 dùng client của JDK, và mọi setting đều là null.
Timeout mặc định của RestClient là bao nhiêu?
Setting null gợi ý là không có timeout, nhưng điều đó cần được đo. Endpoint tồn kho cho ta một server trả lời chậm, còn địa chỉ 10.255.255.1, nơi không gì trả lời trên mạng này, cho ta một kết nối không bao giờ được chấp nhận:
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);
}Để đo một lời gọi có thể không bao giờ trả về, nó chạy trên một daemon thread riêng, và code đo sẽ bỏ cuộc sau một ngưỡng cố định:
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());
}
}
}Với stub chạy bằng java SupplierApiStub.java 8133 90 để endpoint tồn kho ngủ 90 giây, một client từ builder được inject lần lượt trỏ tới stub rồi tới http://10.255.255.1, với ngưỡng 60 và 100 giây:
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 upCả hai lời gọi đều không tự kết thúc. HttpClient của JDK phía sau builder không có connect timeout (connectTimeout() trả về Optional.empty), request factory không có read timeout, và hệ điều hành cũng không hủy nỗ lực kết nối trong vòng 100 giây. Trong một web application, mỗi lời gọi như vậy giữ một request thread của Tomcat chừng nào upstream còn im lặng. Hãy cấu hình cả hai timeout cho mọi upstream.
Đặt timeout bằng spring.http.clients
Các property spring.http.clients áp dụng cho mọi client dựng từ builder được inject:
spring.http.clients.connect-timeout=2s
spring.http.clients.read-timeout=3sspring:
http:
clients:
connect-timeout: 2s
read-timeout: 3sGiá trị là Duration, với các hậu tố từ bài 12. Tên dạng số ít spring.http.client.connect-timeout và spring.http.client.read-timeout vẫn xuất hiện trong các ví dụ cũ; metadata của 4.1.1 đánh dấu chúng deprecated từ 4.0.0, với tên spring.http.clients.* là thay thế.
Khi khởi động lại stub với độ trễ 10 giây và truyền hai property dưới dạng argument --, runner in connectTimeout=PT2S, readTimeout=PT3S ở dòng settings, và hai phép đo lúc nãy giờ đã kết thúc:
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 outCả hai timeout đều xảy ra đúng hẹn, và cả hai đều hiện ra dưới dạng ResourceAccessException, giống kết nối bị từ chối. Trong cùng lần chạy, một client RestClient.create() gọi cùng endpoint không hề bị các property ảnh hưởng:
RestClient.create(), GET /api/stock/KB-87: returned after 10067 ms: StockLevel[sku=KB-87, available=42]Phân biệt connect timeout với read timeout
Khác biệt nằm ở chuỗi exception, và nó phụ thuộc vào HTTP client:
JdkClientHttpRequestFactory (mặc định của Boot) | 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 |
| Đo được | 2004 ms và 3023 ms | 2003 ms và 3022 ms |
Với client của JDK, hai trường hợp có type khác nhau, nhưng HttpConnectTimeoutException kế thừa HttpTimeoutException, nên phải kiểm tra subclass trước. Với simple factory, cả hai đều là SocketTimeoutException và chỉ message mới phân biệt được.

Timeout riêng cho từng client với HttpClientSettings
Timeout toàn cục chỉ là mức nền. Một nhà cung cấp vốn chậm có thể cần read timeout dài hơn mọi upstream khác, nên các giá trị chuyển vào property riêng của nhà cung cấp, với giá trị mặc định qua @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) {
}Class cấu hình tự dựng request factory cho client này, từ hai bean được auto-configure mà runner đã in ra. Import: ClientHttpRequestFactoryBuilder và HttpClientSettings từ org.springframework.boot.http.client, ClientHttpRequestFactory từ 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();
}requestFactoryBuilderlà HTTP client mà Boot đã phát hiện hoặc được chỉ định, nên đoạn code này đi theospring.http.clients.imperative.factoryở phần dưới.httpClientSettings.withTimeouts(connect, read)trả về một bản sao của setting toàn cục với timeout mới và giữ nguyên mọi thứ khác. Khi đặtspring.http.clients.redirects=dont-followtoàn cục,HttpClientcủa client này báofollowRedirects=NEVERbên cạnhconnectTimeout=PT2SvàreadTimeout=PT5S.requestFactory(...)thay thế factory mà configurer của Boot đã gắn vào builder.HttpClientSettingscòn cówithConnectTimeout,withReadTimeoutvà static methoddefaults()với mọi giá trị để trống.
Với property toàn cục vẫn là 2 s và 3 s cùng --app.supplier-api.connect-timeout=1s --app.supplier-api.read-timeout=5s, giá trị riêng của nhà cung cấp thắng:
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 outLời gọi thứ hai còn cho thấy mutate() sao chép request factory: một client dẫn xuất bằng supplierRestClient.mutate().baseUrl("http://10.255.255.1").build() vẫn giữ connect timeout 1 s.
Ngoài các bean được inject, ClientHttpRequestFactoryBuilder có static method cho từng client: jdk(), httpComponents(), jetty(), reactor() và simple(). ClientHttpRequestFactoryBuilder.jdk().build(HttpClientSettings.defaults().withTimeouts(Duration.ofSeconds(2), Duration.ofSeconds(4))) cho ra một factory có read timeout xảy ra sau 4003 ms. Nó luôn dựng client của JDK và bỏ qua mọi property spring.http.clients, nên chỉ nên chọn khi đó đúng là điều bạn muốn.
Đổi HTTP client bằng spring.http.clients.imperative.factory
spring.http.clients.imperative.factory ghi đè cơ chế phát hiện. Type của nó là enum ImperativeHttpClientsProperties.Factory, với các hằng HTTP_COMPONENTS, JETTY, REACTOR, JDK và 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]Giá trị chữ thường simple bind được, và chữ hoa HTTP_COMPONENTS cũng vậy. Với cùng timeout 2 s và 3 s, simple factory tạo ra các chuỗi SocketTimeoutException như trong bảng ở trên, sau 2003 ms và 3022 ms. Chọn một client mà thư viện của nó không có trên classpath không bị phát hiện lúc bind property, mà lúc tạo bean clientHttpRequestFactoryBuilder, và application dừng lại. Phần cuối chuỗi cause với 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 omittedKhi Apache HttpClient 5 đã có trên classpath, cơ chế phát hiện đằng nào cũng chọn nó trước tiên, vì nó đứng đầu thứ tự ở trên. Dù dùng client nào, code kiểm tra exception timeout cũng phải khớp với client đó; handler ở cuối bài kiểm tra các type của client JDK.
Log request đi ra bằng ClientHttpRequestInterceptor
ClientHttpRequestInterceptor nằm giữa RestClient và HTTP client, đúng vị trí trong sơ đồ pipeline. Interceptor dưới đây thêm một header request id và log method, URI, status, thời gian qua SLF4J như đã thiết lập ở bài 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;
}
}Nó được đăng ký trên builder:
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
throw new SupplierApiException(response.getStatusCode(),
new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8));
})
.requestInterceptor(new SupplierApiLoggingInterceptor())
.build();Gọi nhà cung cấp 7, nhà cung cấp 99 và bảng giá, đặt một đơn, rồi gọi port 8139, nơi không có gì lắng nghe, qua một client dẫn xuất bằng mutate(), cho ra log dưới đây; các dòng DEBUG của phần sau đã được lược bỏ:
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.ConnectExceptionvà stub nhận được header, ví dụ:
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.6404 và 500 được log như những response bình thường. Interceptor trả chúng về, và chỉ sau đó onStatus và defaultStatusHandler mới biến chúng thành SupplierNotFoundException và SupplierApiException. Lỗi I/O tới interceptor dưới dạng IOException do execution.execute ném ra, nên kết nối bị từ chối đi vào nhánh catch; read timeout cũng vậy, và đã được log trong lần chạy trả 504 ở cuối bài dưới dạng failed after 3007 ms [...]: java.net.http.HttpTimeoutException: Request cancelled. Interceptor ném lại exception, nên RestClient vẫn bọc nó trong ResourceAccessException.
logging.level.org.springframework.web.client in ra gì?
Log riêng của Spring cho RestClient im lặng hơn interceptor nhiều:
logging.level.org.springframework.web.client=DEBUGVới cùng năm lời gọi, DEBUG chỉ thêm đúng ba dòng:
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]Không có method, URL, status hay thời gian, và hoàn toàn không có gì cho 404, 500 hay kết nối bị từ chối, vì trong các trường hợp đó không body nào được chuyển đổi. TRACE cho org.springframework.web.client và org.springframework.http in ra đúng ba dòng như vậy. Dòng Writing còn đáng lưu ý vì một lý do khác: nó in toString() của object request, nên một record chứa password hay token sẽ nằm luôn trong log. DEBUG giúp xem converter nào được chọn; interceptor mới là thứ cho thấy traffic.
Chuyển lỗi từ upstream thành 502, 503 và 504
API của chính catalogue không nên trả 500 chỉ vì nhà cung cấp gặp sự cố. Mỗi loại lỗi từ upstream có một status gateway tương ứng: 502 Bad Gateway khi upstream trả lời bằng lỗi, 504 Gateway Timeout khi nó không trả lời kịp, và 503 Service Unavailable khi không kết nối được tới nó. Bài 20 nói về @RestControllerAdvice và ProblemDetail; ở đây chỉ có 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");
}
}Phía product đưa dữ liệu nhà cung cấp ra ngoài từ package product:
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();
}
}Stub chạy với độ trễ tồn kho 10 giây, còn application đặt read timeout 3 giây cho nhà cung cấp:
java SupplierApiStub.java 8133 10java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8123 --app.supplier-api.read-timeout=3sBảng giá, nơi upstream trả 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"}Tồn kho, nơi upstream ngủ lâu hơn read timeout. Response về sau 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"}Một nhà cung cấp, sau khi đã dừng 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"}Trước khi dừng stub, /api/products/suppliers/99 trả 404 với {"detail":"Supplier 99 does not exist","instance":"/api/products/suppliers/99","status":404,"title":"Not Found"}. Khi khởi động lại application với --app.supplier-api.base-url=http://10.255.255.1, /api/products/KB-87/stock trả đúng response 503 đó sau 2,10 s, tức connect timeout mặc định 2 s. Có ba điều cần biết về handler này. ResourceAccessException đến từ mọi RestClient trong application, nên handler đại diện cho mọi upstream, đó là lý do message ghi "Upstream" thay vì "Supplier API". Phần kiểm tra timeout của nó khớp với các type exception của client JDK, như đã nói ở phần đổi HTTP client. Và nó là một advice riêng: bài 20 đã cho thấy Spring hỏi các advice theo giá trị @Order từ thấp lên cao và advice đầu tiên có bất kỳ handler nào khớp sẽ trả lời, nên nếu bên cạnh có một GlobalExceptionHandler với handler catch-all cho Exception, hãy cho class này một @Order rõ ràng đứng trước nó, hoặc chuyển các method này vào class đó.
Các lỗi của RestClient và cách xử lý
Mọi dòng dưới đây đều được tạo ra trong bài này, với client JDK mà Boot chọn mặc định:
| Lỗi | Exception mặc định | Cách xử lý |
|---|---|---|
| Response 4xx | subclass của HttpClientErrorException như NotFound; message 404 Not Found: "..." kèm body | onStatus cho status có ý nghĩa nghiệp vụ; getResponseBodyAs(...) để đọc body lỗi |
| Response 5xx | subclass của HttpServerErrorException như InternalServerError | defaultStatusHandler trên builder; trả 502 |
| Status là kết quả bình thường, như 404 | cùng HttpClientErrorException đó với retrieve() | exchange, trả về Optional.empty() hoặc giá trị mặc định |
| Kết nối bị từ chối | ResourceAccessException, cause ConnectException: null | trả 503 |
| Connect timeout | ResourceAccessException, cause HttpConnectTimeoutException: HTTP connect timed out | spring.http.clients.connect-timeout hoặc đặt cho từng client; trả 503 |
| Read timeout | ResourceAccessException, cause HttpTimeoutException: Request cancelled | spring.http.clients.read-timeout hoặc đặt cho từng client; trả 504 |
| Không cấu hình timeout, upstream im lặng | không có: lời gọi vẫn còn chờ sau 60 s | luôn đặt cả hai timeout |
| Body không khớp type đích | RestClientException: Error while extracting response for type [...] | sửa DTO hoặc cấu hình Jackson |
| Status handler return mà không ném | body lỗi bị chuyển đổi vào type của trường hợp thành công, và thường thất bại | luôn ném exception từ status handler |
Vài chủ đề liên quan được cố ý để lại sau. Retry lời gọi thất bại và mở circuit breaker khi upstream lỗi liên tục thuộc về khóa Advanced. Test SupplierClient mà không cần stub đang chạy, với MockRestServiceServer và @RestClientTest, nằm trong Chương 6. Upstream HTTPS cần trust store riêng thì dùng SSL bundle, chọn toàn cục bằng spring.http.clients.ssl.bundle hoặc cho từng client qua bean RestClientSsl của Boot.
FAQ
RestTemplate có bị deprecated trong Spring Boot 4 không?
Chưa, ở Spring Boot 4.1.1: RestTemplate trong Spring Framework 7.0.9 không có annotation @Deprecated. Đội Spring dự định chính thức deprecate nó ở Framework 7.1, dự kiến tháng 11/2026, và xóa ở Framework 8.0, như đã thông báo trên blog spring.io vào tháng 9/2025 và theo dõi ở spring-framework issue #36574. Code mới hãy viết bằng RestClient.
Timeout mặc định của RestClient trong Spring Boot là bao nhiêu?
Không có. Khi không cấu hình gì, Boot 4.1.1 dựng HttpClient của JDK không có connect timeout lẫn read timeout: một lời gọi tới server không trả lời vẫn còn chờ sau 60 giây, và một nỗ lực kết nối tới địa chỉ không với tới được vẫn treo sau 100 giây. Hãy đặt spring.http.clients.connect-timeout và spring.http.clients.read-timeout, hoặc cho một client giá trị riêng qua HttpClientSettings.
Vì sao Spring Boot không tìm thấy bean RestClient.Builder?
Vì thiếu spring-boot-starter-restclient. spring-boot-starter-webmvc kéo class RestClient vào nhưng không có auto-configuration định nghĩa bean builder, nên khởi động thất bại với required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found. Hãy thêm starter thay vì tự định nghĩa bean builder.
Làm sao đặt timeout cho riêng một RestClient?
Inject ClientHttpRequestFactoryBuilder<?> và HttpClientSettings, dựng factory bằng requestFactoryBuilder.build(httpClientSettings.withTimeouts(connect, read)), rồi truyền cho requestFactory(...) trên builder. Các giá trị này ghi đè spring.http.clients.* chỉ cho client đó, các setting toàn cục khác được giữ nguyên, và client dẫn xuất bằng mutate() thừa hưởng chúng.
RestClient có ném exception khi gặp 404 không?
Có, với retrieve(): HttpClientErrorException.NotFound, với message là 404 Not Found: theo sau là body của response trong ngoặc kép. Đăng ký onStatus để ném exception của riêng bạn, hoặc dùng exchange khi 404 là kết quả bình thường cần trở thành thứ như Optional.empty().
Khi nào nên dùng exchange thay vì retrieve?
Khi một status là kết quả được dự tính chứ không phải lỗi, hoặc khi một quyết định cần cả status, header và body cùng lúc. Bên trong exchange không status handler nào chạy, kể cả defaultStatusHandler của builder, nên function của bạn phải tự xử lý mọi status; response.createException() cho bạn exception mặc định khi cần. Lỗi mạng vẫn ném ResourceAccessException, y như với retrieve().
Kết luận
RestClient là HTTP client đồng bộ của Spring dành cho code mới, và trong Spring Boot nó nên được dựng từ RestClient.Builder được inject của spring-boot-starter-restclient: builder mang theo Jackson mapper của Boot, các setting spring.http.clients và các customizer, đồng thời mỗi bean nhận một builder riêng. Mỗi upstream một bean RestClient đặt base URL và default header, và một class client nhỏ giữ HTTP tách khỏi phần còn lại của code. GET, POST, PUT và DELETE chuyển đổi record theo cả hai chiều, toEntity thêm status và header, còn URI variable là cách an toàn duy nhất để đưa giá trị vào URI. Lỗi thuộc hai họ: status, thứ bạn định hình bằng onStatus, defaultStatusHandler hoặc exchange, và lỗi I/O, luôn đến dưới dạng ResourceAccessException. Timeout không tồn tại cho tới khi bạn đặt nó, nên mọi upstream đều cần cả hai. Một interceptor làm traffic hiện ra, và một handler ngắn biến tất cả thành 502, 503 và 504 cho client của chính catalogue.
Bài tiếp theo là bài tùy chọn và rẽ sang một hướng khác: server-side rendering với Thymeleaf, dành cho người muốn làm web truyền thống thay vì JSON API.