Command Palette

Search for a command to run...

[Spring Boot Basics] Gọi API bên ngoài với RestClient trong Spring Boot: GET, POST, xử lý lỗi và timeout

Đế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ột cửa sổ code gửi request GET qua RestClient tới một API bên ngoài; thứ quay về có thể là 200, 404, 500 hoặc không có gì

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. RestTemplateRestClient 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.

ClientCó từKiểu APIBlockingTrong khóa học này
RestTemplateFramework 3.0template method: getForObject, postForEntity, exchangekhông dùng
RestClientFramework 6.1fluent: get().uri(...).retrieve().body(...)bài này
WebClientFramework 5.0fluent, trả về MonoFluxkhông, reactivekhóa Advanced
HTTP interface với @HttpExchangeFramework 6.0một Java interface, Spring tự sinh phần implementation dựa trên RestClient hoặc WebClienttùy client bên dướikhó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:

SupplierApiStub.java
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:

Bash
java SupplierApiStub.java 8133
Text
Supplier API stub on port 8133, stock delay 0s
RequestResponse
GET /api/suppliers200, hai nhà cung cấp
GET /api/suppliers/7200, một nhà cung cấp, kèm header X-Rate-Limit-Remaining: 99
GET /api/suppliers/{id}, id khác404, {"code":"SUPPLIER_NOT_FOUND",...}
GET /api/suppliers/search200, []
POST /api/purchase-orders201 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-list500, {"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:

build.gradle
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'
}

./gradlew dependencies --configuration runtimeClasspath, cắt gọn chỉ còn nhánh do starter thêm vào:

Text
+--- 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.1

Hai 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:

Java
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 injectRestClient.create()
HTTP clientJdkClientHttpRequestFactory, do ClientHttpRequestFactoryBuilder của Boot tạoJdkClientHttpRequestFactory, do Spring Framework tạo
spring.http.clients.connect-timeout / read-timeoutđược áp dụngbị 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 NEVERkhông đi theo (NEVER)
Message convertercác converter ByteArray, String, Resource, AllEncompassingFormJacksonJsonđúng năm converter đó
JSON mapperbean jacksonJsonMapper của Boot, nên spring.jackson.* có tác dụngmột JsonMapper riêng
Bean RestClientCustomizerđược áp dụngkhô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:

Text
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:

Text
***************************
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:

src/main/java/com/example/demo/supplier/SupplierApiProperties.java
package com.example.demo.supplier;
 
import org.springframework.boot.context.properties.ConfigurationProperties;
 
@ConfigurationProperties("app.supplier-api")
public record SupplierApiProperties(String baseUrl, String apiKey) {
}
src/main/resources/application.properties
app.supplier-api.base-url=http://localhost:8133
app.supplier-api.api-key=demo-key-123

Một class cấu hình biến builder được inject thành một bean RestClient cho upstream này:

src/main/java/com/example/demo/supplier/SupplierClientConfig.java
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:

src/main/java/com/example/demo/supplier/Supplier.java
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:

src/main/java/com/example/demo/supplier/SupplierClient.java
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ề:

Text
Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5]

và stub in ra:

Text
GET /api/suppliers/7
    X-Api-Key: demo-key-123
    User-Agent: Java-http-client/21.0.6

Ba chi tiết trong hai output đó:

  • rating bị bỏ qua. Jackson 3 bỏ qua property lạ trừ khi được cấu hình khác.
  • Không có header Accept nào được gửi. Stub in Accept bất cứ khi nào request có header này, và một request curl tới nó hiện Accept: */*; 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ột ResponseSpec. Một interceptor in ra từng request đi ra đã im lặng suốt một giây giữa lúc retrieve() trả về và lúc body() được gọi, rồi mới in request bên trong body().

Tám bước đánh số của một lời gọi RestClient: cấu hình builder, request spec, expand URI template, body của request, interceptor, HTTP client của JDK, status handler và chuyển body thành Supplier

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:

src/main/java/com/example/demo/supplier/SupplierClient.java
    public List<Supplier> findSuppliers() {
        return restClient.get()
                .uri("/api/suppliers")
                .retrieve()
                .body(new ParameterizedTypeReference<List<Supplier>>() {});
    }
Text
[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:

Java
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());
Text
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:

Java
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:

Text
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%ADq=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%ADq=C++ & bàn phím
C: uri("...?q={q}&country={country}")%2B%20%26%C3%A0, %C3%ADq=C++ & bàn phím
D: nối chuỗi+%20&%C3%A0, %C3%ADba parameter: q=C , bàn phímcountry=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:

src/main/java/com/example/demo/supplier/PurchaseOrderRequest.java
package com.example.demo.supplier;
 
public record PurchaseOrderRequest(long supplierId, String sku, int quantity) {
}
src/main/java/com/example/demo/supplier/PurchaseOrder.java
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 MediaTypeResponseEntity:

src/main/java/com/example/demo/supplier/SupplierClient.java
    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ó:

Java
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());
Text
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=null

Những gì stub nhận được:

Text
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.6
  • body(order) đi qua đúng các message converter dùng cho response, theo chiều ngược lại: JacksonJsonHttpMessageConverter ghi 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ới Content-Type: application/json, vì converter đã tự chọn type đó.
  • toEntity(PurchaseOrder.class) là cách đọc một response 201: status, header Location qua getHeaders().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ọi body(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ỏ:

src/main/java/com/example/demo/supplier/UpstreamError.java
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:

Java
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());
}
Text
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/json

Cùng đoạn code đó bọc quanh lời gọi bảng giá, endpoint trả 500:

src/main/java/com/example/demo/supplier/SupplierClient.java
    public String priceList() {
        return restClient.get()
                .uri("/api/price-list")
                .retrieve()
                .body(String.class);
    }
Text
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/json

Cò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òn getResponseBodyAs(UpstreamError.class) chuyển nó thành một type.
  • Tất cả đều là unchecked exception. HttpClientErrorException.NotFound kế thừa HttpClientErrorException, rồi tới HttpStatusCodeException, RestClientResponseException, RestClientException, NestedRuntimeExceptionRuntimeException.
  • 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, UnprocessableContentTooManyRequests bên trong HttpClientErrorException, cùng InternalServerError, NotImplemented, BadGateway, ServiceUnavailableGatewayTimeout bên trong HttpServerErrorException.

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:

src/main/java/com/example/demo/supplier/SupplierNotFoundException.java
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:

src/main/java/com/example/demo/supplier/SupplierClient.java
    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:

Text
com.example.demo.supplier.SupplierNotFoundException: Supplier 99 does not exist

Predicate nhận một HttpStatusCode, nên HttpStatusCode::is4xxClientError, HttpStatusCode::is5xxServerErrorHttpStatusCode::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:

src/main/java/com/example/demo/supplier/SupplierApiException.java
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;
    }
}

defaultStatusHandler cài nó cho mọi request đi qua bean, với import cho HttpStatusCodeStandardCharsets:

src/main/java/com/example/demo/supplier/SupplierClientConfig.java
    @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:

Java
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());
    }
}
Text
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:

src/main/java/com/example/demo/supplier/SupplierClient.java
    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));
                });
    }
Text
7  -> Optional[Supplier[id=7, name=Hanoi Keyboards, country=VN, leadTimeDays=5]]
99 -> Optional.empty

Vớ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:

Text
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.

Ba làn: không có response thì retrieve() và exchange() đều ném ResourceAccessException; retrieve() có status đi qua onStatus, rồi defaultStatusHandler, rồi cơ chế mặc định; exchange() thấy mọi status mà không handler nào chạy

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 ClientHttpRequestFactoryBuilderHttpClientSettings 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:

src/main/java/com/example/demo/supplier/HttpClientReport.java
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);
        };
    }
}
Text
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:

src/main/java/com/example/demo/supplier/StockLevel.java
package com.example.demo.supplier;
 
public record StockLevel(String sku, int available) {
}
src/main/java/com/example/demo/supplier/SupplierClient.java
    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:

Java
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:

Java
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));
Text
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 up

Cả 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:

src/main/resources/application.properties
spring.http.clients.connect-timeout=2s
spring.http.clients.read-timeout=3s

Giá trị là Duration, với các hậu tố từ bài 12. Tên dạng số ít spring.http.client.connect-timeoutspring.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:

Text
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 out

Cả 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:

Text
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 timeoutcause java.net.http.HttpConnectTimeoutException: HTTP connect timed outcause java.net.SocketTimeoutException: Connect timed out
Read timeoutcause java.net.http.HttpTimeoutException: Request cancelledcause java.net.SocketTimeoutException: Read timed out
Đo được2004 ms và 3023 ms2003 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.

Một dòng thời gian mười một giây: kết nối tới 10.255.255.1 bị cắt ở 2004 ms bởi HttpConnectTimeoutException; request tới stub đang ngủ bị cắt ở 3023 ms bởi HttpTimeoutException; không có timeout thì cả hai tiếp tục chờ, và RestClient.create() nhận 200 sau 10067 ms

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:

src/main/java/com/example/demo/supplier/SupplierApiProperties.java
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: ClientHttpRequestFactoryBuilderHttpClientSettings từ org.springframework.boot.http.client, ClientHttpRequestFactory từ org.springframework.http.client:

src/main/java/com/example/demo/supplier/SupplierClientConfig.java
    @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();
    }
  • requestFactoryBuilder là HTTP client mà Boot đã phát hiện hoặc được chỉ định, nên đoạn code này đi theo spring.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 đặt spring.http.clients.redirects=dont-follow toàn cục, HttpClient của client này báo followRedirects=NEVER bên cạnh connectTimeout=PT2SreadTimeout=PT5S.
  • requestFactory(...) thay thế factory mà configurer của Boot đã gắn vào builder. HttpClientSettings còn có withConnectTimeout, withReadTimeout và static method defaults() 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:

Text
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 out

Lờ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()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, JDKSIMPLE:

src/main/resources/application.properties
spring.http.clients.imperative.factory=simple
Text
factory 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:

Text
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 omitted

Khi 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:

src/main/java/com/example/demo/supplier/SupplierApiLoggingInterceptor.java
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:

src/main/java/com/example/demo/supplier/SupplierClientConfig.java
                .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ỏ:

Text
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.ConnectException

và stub nhận được header, ví dụ:

Text
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.6

404 và 500 được log như những response bình thường. Interceptor trả chúng về, và chỉ sau đó onStatusdefaultStatusHandler mới biến chúng thành SupplierNotFoundExceptionSupplierApiException. 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:

src/main/resources/application.properties
logging.level.org.springframework.web.client=DEBUG

Với cùng năm lời gọi, DEBUG chỉ thêm đúng ba dòng:

Text
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.clientorg.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ề @RestControllerAdviceProblemDetail; ở đây chỉ có handler:

src/main/java/com/example/demo/supplier/UpstreamFailureHandler.java
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:

src/main/java/com/example/demo/product/ProductSupplierController.java
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:

Bash
java SupplierApiStub.java 8133 10
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8123 --app.supplier-api.read-timeout=3s

Bảng giá, nơi upstream trả 500:

Bash
curl -i localhost:8123/api/products/price-list
Text
HTTP/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:

Bash
curl -i localhost:8123/api/products/KB-87/stock
Text
HTTP/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:

Bash
curl -i localhost:8123/api/products/suppliers/7
Text
HTTP/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ỗiException mặc địnhCách xử lý
Response 4xxsubclass của HttpClientErrorException như NotFound; message 404 Not Found: "..." kèm bodyonStatus cho status có ý nghĩa nghiệp vụ; getResponseBodyAs(...) để đọc body lỗi
Response 5xxsubclass của HttpServerErrorException như InternalServerErrordefaultStatusHandler trên builder; trả 502
Status là kết quả bình thường, như 404cùng HttpClientErrorException đó với retrieve()exchange, trả về Optional.empty() hoặc giá trị mặc định
Kết nối bị từ chốiResourceAccessException, cause ConnectException: nulltrả 503
Connect timeoutResourceAccessException, cause HttpConnectTimeoutException: HTTP connect timed outspring.http.clients.connect-timeout hoặc đặt cho từng client; trả 503
Read timeoutResourceAccessException, cause HttpTimeoutException: Request cancelledspring.http.clients.read-timeout hoặc đặt cho từng client; trả 504
Không cấu hình timeout, upstream im lặngkhông có: lời gọi vẫn còn chờ sau 60 sluôn đặt cả hai timeout
Body không khớp type đíchRestClientException: Error while extracting response for type [...]sửa DTO hoặc cấu hình Jackson
Status handler return mà không némbody 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ạiluô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@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-timeoutspring.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<?>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.

Bài viết liên quan

[Spring Boot Basics] Server-side rendering với Thymeleaf trong Spring Boot: template, form và validation

Server-side rendering với Thymeleaf 3.1.5 trong Spring Boot 4.1.1, kiểm chứng trên application đang chạy: khi nào SSR hợp hơn JSON API, view name trở thành classpath:/templates/products/list.html ra sao, năm loại standard expression, th:text và th:utext với XSS, th:each cùng #numbers và #temporals, form tạo mới có validation với th:field và th:errors, vị trí bắt buộc của BindingResult, Post/Redirect/Get với flash attribute, fragment, CSS tĩnh, spring.thymeleaf.cache thực sự thay đổi gì, và trang lỗi HTML.

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3.1.5, kiểm chứng trên project thật: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.

[Spring Boot Basics] @RestController và request mapping trong Spring Boot: @GetMapping, @PostMapping, @PutMapping, @DeleteMapping

Cách @RestController và request mapping hoạt động trong Spring Boot 4.1.1, kiểm chứng trên project thật: @Controller so với @ResponseBody, đường đi của một request qua DispatcherServlet, @GetMapping, @PostMapping, @PutMapping, @PatchMapping và @DeleteMapping trên một controller CRUD, path pattern và mapping nào thắng, dấu gạch chéo cuối URL, consumes, produces, params và headers cùng status code mà mỗi lần không khớp trả về, HEAD và OPTIONS ngầm định, và mỗi kiểu giá trị trả về trở thành response như thế nào.

[Spring Boot Basics] IoC và Dependency Injection trong Spring: vì sao không còn tự new object

Ý tưởng mà cả framework dựa lên, demo bằng code chạy thật trên Spring Boot 4.1.1 và Java 21: một object graph bốn class tự new ở mọi tầng cùng ba hậu quả kéo theo, phân biệt rạch ròi Inversion of Control với Dependency Injection, nối tay cùng graph đó trong main mà không có framework nào, rồi để Spring container nối và in identity của từng instance ra để chứng minh, kèm một test JUnit 5 với stub tự viết, một lần đổi implementation mà không đụng vào class đang dùng nó, và danh sách thành thật những cái giá phải trả.