Spring Production-Ready/RestClient — gọi HTTP ra ngoài mà không mất kiểm soát
5/26
Bài 5 / 26~12 phútModern Java trong SpringMiễn phí lượt xem

RestClient — gọi HTTP ra ngoài mà không mất kiểm soát

RestClient thay RestTemplate: API fluent, timeout phải khai bằng tay, error handler quyết định lỗi 4xx/5xx thành exception gì trong service của bạn.

TL;DR: RestTemplate không đặt timeout mặc định — gọi downstream mà nó treo thì thread gọi cũng treo theo, đủ nhiều request treo cùng lúc thì cả thread pool cạn, node ngừng nhận request mới. Bài này dựng RestClient (Spring Framework 6.1) làm bean dùng chung, khai connect timeout và read timeout bằng tay, rồi dùng onStatus dịch lỗi 4xx/5xx từ downstream thành exception nghiệp vụ thay vì để nó rò nguyên dạng lên controller.

1. Vì sao RestTemplate không set timeout lại làm treo cả node?

TaskFlow có endpoint GET /boards/{id} build màn hình board: lấy danh sách task, rồi với mỗi task gọi sang Profile Service (nội bộ) để lấy tên và avatar người được giao việc (assignee). Code gọi bằng RestTemplate, viết từ những ngày đầu dự án:

@Service
public class ProfileLookupService {

    private final RestTemplate restTemplate;

    ProfileLookupService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public UserProfileResponse fetchProfile(Long userId) {
        String url = "http://profile-service.internal/api/users/" + userId;
        return restTemplate.getForObject(url, UserProfileResponse.class);
    }
}

Không có dòng nào cấu hình timeout. RestTemplate mặc định không set connect timeout hay read timeout — chờ tới khi nào có phản hồi thì chờ. Một hôm Profile Service kẹt vì transaction DB bị lock không rollback: nhận connection TCP bình thường, nhưng không bao giờ trả response.

Mỗi request tới /boards/{id} gọi fetchProfile bị treo vô thời hạn ở tầng ứng dụng (OS có timeout riêng cho TCP, nhưng đó là hàng phút tới hàng chục phút, quá đủ để gây hại trước khi kịp can thiệp). Thread trong pool Tomcat bị chiếm giữ không nhả ra, tới khi pool cạn thì node ngừng nhận request mới, kể cả request chẳng liên quan gì tới Profile Service. Không phải downstream lỗi (lỗi còn trả response), mà downstream im lặng không trả gì cả — một downstream chậm kéo sập toàn bộ node.

2. Ba client HTTP, chọn đúng cho đúng việc

Spring cung cấp ba cách gọi HTTP ra ngoài từ code Java, không phải ba phiên bản của cùng một thứ:

ClientTừ khi nàoMô hìnhKhi nào dùng
RestTemplateCó từ Spring 3.0Blocking, API cũ (getForObject, postForEntity...)Code cũ đang chạy tốt — không bắt buộc migrate ngay
WebClientSpring 5 (WebFlux)Non-blocking, reactive (Mono/Flux)Ứng dụng đã chạy trên stack reactive, hoặc cần streaming/backpressure thật sự
RestClientSpring Framework 6.1 (Boot 3.2+)Blocking, API fluent hiện đạiCode mới trong ứng dụng blocking (MVC) — thay RestTemplate

Từ Spring Framework 6.1, RestTemplate chuyển sang chế độ bảo trì (maintenance mode): vẫn chạy, vẫn được hỗ trợ, nhưng không nhận thêm tính năng mới — không phải deprecated, code cũ không bị ép migrate. WebClient đúng khi cả pipeline đã reactive; gọi nó từ một service blocking như TaskFlow chỉ kéo Mono/Flux vào chỗ không cần, không lợi gì thêm. Lane bài này là RestClient — giữ mô hình blocking quen thuộc, chỉ đổi API.

3. Dựng RestClient như một bean dùng chung

Spring Boot 3.2 trở lên tự động cấp một bean RestClient.Builder (scope prototype — mỗi nơi inject nhận một builder riêng, không đụng cấu hình lẫn nhau). Dùng builder đó trong một @Configuration để dựng RestClient thành bean dùng chung (gọi .baseUrl(...) rồi .build()) thay vì new RestClient() rải rác trong từng service (phần 4 sẽ hoàn thiện bean này với timeout).

ProfileLookupService nhận thẳng RestClient đã cấu hình sẵn, thay vì RestTemplate chung chung:

@Service
public class ProfileLookupService {

    private final RestClient profileRestClient;

    ProfileLookupService(RestClient profileRestClient) {
        this.profileRestClient = profileRestClient;
    }

    public UserProfileResponse fetchProfile(Long userId) {
        return profileRestClient.get()
            .uri("/users/{id}", userId)
            .retrieve()
            .body(UserProfileResponse.class);
    }
}

Bạn đã dựng DTO bằng record ở bài 01 — record deserialize response JSON không cần constructor rỗng, không cần setter, Jackson tự nhận diện field theo tên. .body(UserProfileResponse.class) dùng lại đúng cơ chế converter đó: response JSON từ Profile Service map thẳng vào record.

public record UserProfileResponse(
    Long userId,
    String displayName,
    String email,
    String avatarUrl
) {}

Vì sao inject RestClient.Builder thay vì new trực tiếp: builder Boot cấp đã sẵn message converter (Jackson, đúng những gì bài 01 đã dùng), đã đăng ký instrumentation (Micrometer Observation nếu bạn bật), và là một điểm cấu hình chung — đổi timeout hay header mặc định ở một bean áp dụng cho mọi request qua nó, không phải sửa từng chỗ gọi new.

4. Timeout: mặc định là chờ mãi, phải khai bằng tay

RestClient.Builder không tự đặt timeout nào. Khai timeout nghĩa là chọn một ClientHttpRequestFactory có set connect timeout và read timeout, rồi gắn nó vào builder. Từ Spring Boot 3.4, cách làm chuẩn là qua ClientHttpRequestFactorySettings (package org.springframework.boot.http.client) và ClientHttpRequestFactoryBuilder.detect() — tự nhận diện HTTP client có sẵn trên classpath (Apache HttpClient, Jetty, JDK HttpClient...) rồi build factory theo settings:

@Bean
RestClient profileRestClient(RestClient.Builder builder) {
    ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
        .withConnectTimeout(Duration.ofSeconds(2))
        .withReadTimeout(Duration.ofSeconds(5));
    ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings);

    return builder
        .baseUrl("http://profile-service.internal/api")
        .requestFactory(requestFactory)
        .build();
}

Hai timeout này chặn hai khoảnh khắc khác nhau của một lời gọi, và cả hai đều cần khai riêng:

flowchart LR
    A[RestClient.get] -->|"connect timeout 2s"| B[TCP handshake]
    B --> C[Profile Service]
    C -->|"read timeout 5s"| D[Response body]
    D --> E[ProfileLookupService]
  • Connect timeout — thời gian chờ bắt tay TCP thành công. Giống bạn gọi điện mà đầu dây bên kia không ai nhấc máy: hết thời gian này mà kết nối chưa mở được, bỏ cuộc luôn, không tốn thêm giây nào chờ tiếp.
  • Read timeout — thời gian chờ response sau khi kết nối đã mở. Giống đầu dây kia nhấc máy rồi im lặng không nói gì: kết nối vẫn sống, nhưng không có gì trả về. Đây chính xác là tình huống Profile Service bị lock DB ở phần 1 — TCP handshake thành công bình thường, chỉ có response là không bao giờ tới.

Thiếu một trong hai là vẫn còn lỗ hổng: chỉ set connect timeout thì downstream nhấc máy rồi im lặng vẫn treo vô hạn; chỉ set read timeout thì downstream chết hẳn (không nhận connection) vẫn treo ở bước bắt tay. Không có hằng số đúng cho mọi downstream — connect timeout nên ngắn (1-3 giây, cùng datacenter bắt tay TCP chỉ mất mili-giây), read timeout phải khớp SLA thật của downstream đang gọi.

5. Lỗi downstream có nên rò lên controller không?

RestClient mặc định ném exception khi response là 4xx hoặc 5xx: HttpClientErrorException cho 4xx, HttpServerErrorException cho 5xx (cả hai là subtype của RestClientResponseException) — hành vi này không mới, RestTemplate cũng vậy.

Thử đoán trước khi đọc tiếp

profileRestClient chưa cấu hình gì cho lỗi. Profile Service trả 404 vì userId không tồn tại. fetchProfile ném ra loại exception nào, và controller cuối cùng trả mã HTTP nào cho client TaskFlow? Đoán trước khi đọc tiếp.

fetchProfile ném HttpClientErrorException.NotFound (unchecked). Không có @ExceptionHandler nào bắt đúng loại này, nó rơi lên tận DispatcherServlet — Spring trả về 500 Internal Server Error, không phải 404. Đây là bug thật: downstream nói rõ "không tìm thấy user này", client lại biến nó thành "lỗi server không xác định" — sai semantics lẫn trải nghiệm debug.

Fix bằng onStatus: dịch lỗi downstream thành một exception nghiệp vụ của riêng TaskFlow, ngay tại điểm gọi. Nhánh 4xx viết sẵn dưới đây — tự viết tiếp nhánh 5xx trước khi xem đáp án:

.onStatus(HttpStatusCode::is4xxClientError, (request, response) ->
    throwUpstreamException(userId, response))
// TODO: them nhanh onStatus cho is5xxServerError, cung goi throwUpstreamException
Tự điền trước khi xem đáp án

Viết dòng onStatus còn thiếu cho nhóm 5xx, gọi cùng throwUpstreamException. Thiếu nhánh này thì lỗi 500 từ downstream rò nguyên dạng lên controller — đúng bug mở đầu phần này.

Đáp án — ghép đủ hai nhánh vào method:

public UserProfileResponse fetchProfile(Long userId) {
    return profileRestClient.get()
        .uri("/users/{id}", userId)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, (request, response) ->
            throwUpstreamException(userId, response))
        .onStatus(HttpStatusCode::is5xxServerError, (request, response) ->
            throwUpstreamException(userId, response))
        .body(UserProfileResponse.class);
}

private void throwUpstreamException(Long userId, ClientHttpResponse response) throws IOException {
    throw new ProfileServiceException(
        response.getStatusCode(),
        "Profile service tra loi cho user " + userId + ": " + response.getStatusCode());
}

ProfileServiceException là exception nghiệp vụ bạn tự định nghĩa, mang theo mã lỗi gốc từ downstream:

public class ProfileServiceException extends RuntimeException {

    private final HttpStatusCode upstreamStatus;

    public ProfileServiceException(HttpStatusCode upstreamStatus, String message) {
        super(message);
        this.upstreamStatus = upstreamStatus;
    }

    public HttpStatusCode upstreamStatus() {
        return upstreamStatus;
    }
}

spring-rest-data đã dạy @RestControllerAdvice map exception sang ProblemDetail — áp lại đúng pattern đó:

@ExceptionHandler(ProfileServiceException.class)
ProblemDetail handleProfileServiceFailure(ProfileServiceException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
    problem.setTitle("Upstream profile service unavailable");
    problem.setDetail(ex.getMessage());
    return problem;
}

Luồng đầy đủ, từ lỗi downstream tới response cuối cùng client TaskFlow nhận được:

flowchart LR
    A[Profile Service] -->|"4xx hoac 5xx"| B[RestClient onStatus]
    B --> C[ProfileServiceException]
    C --> D[RestControllerAdvice]
    D --> E[ProblemDetail 502]

HTTP 502 Bad Gateway nói đúng bản chất: lỗi nằm ở service phía sau mà TaskFlow phụ thuộc vào, không phải ở chính TaskFlow — khác 500 (lỗi không rõ nguyên nhân) và khác việc giữ nguyên mã lỗi downstream trả về.

Liên hệ bài 03

Connection pool của RestClient cũng là tài nguyên hữu hạn — cùng họ với mất backpressure bài 03 đã nói. Timeout ngăn một request treo mãi, nhưng nếu tốc độ gọi vượt tốc độ pool giải phóng connection, request vẫn xếp hàng chờ. Hai cơ chế bảo vệ khác nhau, không thay thế nhau được.

Pitfall thường gặp

new RestClient() rải rác trong từng service:

public class ProfileLookupService {
    private final RestClient client = RestClient.create("http://profile-service.internal/api");
    // moi service tu tao rieng, khong chia se cau hinh
}

✅ Mỗi chỗ new như vậy mất cấu hình Boot đã chuẩn bị sẵn qua RestClient.Builder — message converter, timeout, instrumentation. Sửa timeout ở một bean @Configuration áp dụng cho mọi service dùng chung; sửa từng new là sửa N lần và dễ bỏ sót.

Chỉ bắt 4xx trong onStatus, quên 5xx:

.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> throwUpstreamException(userId, response))
// khong co dong cho is5xxServerError

✅ Khi Profile Service trả 500, request rơi ra khỏi handler bạn viết và RestClient tự ném HttpServerErrorException mặc định — rò nguyên dạng lên controller, y hệt tình huống ở phần 5 trước khi fix. onStatus phải phủ cả is4xxClientError lẫn is5xxServerError, hoặc gộp bằng HttpStatusCode::isError.

Đào sâu

Đào sâu — spec và tài liệu chính thức

Liên hệ các bài khác

  • Bài 01 — Record làm DTO: UserProfileResponse dùng lại cơ chế deserialize record đã học.
  • Bài 03 — Virtual threads: cạm bẫy: connection pool của RestClient cùng họ tài nguyên hữu hạn với mất backpressure đã bàn ở đó.
  • spring-rest-dataProblemDetail: advice map ProfileServiceException sang ProblemDetail tái dùng đúng pattern bài đó.
  • Bài 05 — HTTP Interface với @HttpExchange: nhiều downstream hơn thì viết RestClient tay bắt đầu lặp code — @HttpExchange giải quyết đúng chỗ đó.

Tóm tắt

  • Không set timeout, RestClient/RestTemplate chờ downstream vô thời hạn ở tầng ứng dụng — một downstream treo có thể cạn hết thread pool và kéo sập cả node.
  • Dựng RestClient qua RestClient.Builder mà Boot cấp làm bean dùng chung, không new rải rác từng service.
  • Connect timeout (chờ bắt tay TCP) và read timeout (chờ response sau khi đã kết nối) là hai giới hạn độc lập — thiếu một là vẫn còn lỗ hổng treo vô hạn.
  • onStatus phải phủ cả 4xx lẫn 5xx, dịch lỗi downstream thành exception nghiệp vụ mang theo mã lỗi gốc, rồi map sang ProblemDetail — đừng để exception của framework rò thẳng lên controller.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao RestClient không cấu hình timeout thì coi như "chờ mãi" ở tầng ứng dụng, dù về lý thuyết hệ điều hành luôn có một giới hạn TCP nào đó? Giải thích khác nhau giữa connect timeout và read timeout.

Không set timeout, socket dùng mặc định chờ vô hạn ở tầng ứng dụng — OS có cơ chế phát hiện kết nối chết riêng (retransmission, keepalive), nhưng đó là hàng phút tới hàng chục phút, đủ lâu để cạn hết thread pool trước khi OS kịp can thiệp. Connect timeout giới hạn thời gian chờ bắt tay TCP (gọi điện không ai nhấc máy, hết giờ bỏ cuộc ngay); read timeout giới hạn thời gian chờ response sau khi đã kết nối (nhấc máy rồi im lặng). Hai cơ chế chặn hai khoảnh khắc khác nhau của cùng một lời gọi, phải khai cả hai.

Q2
Profile Service trả 404 cho một user không tồn tại. RestClient bean của bạn chưa cấu hình onStatus. Giải thích cơ chế: exception nào được ném ra, và mã HTTP cuối cùng controller trả về cho client TaskFlow là gì? Vì sao đó là một bug.

RestClient mặc định ném HttpClientErrorException.NotFound (unchecked) khi response là 4xx. Không có @ExceptionHandler nào bắt đúng loại này thì nó rơi lên tận DispatcherServlet như exception không xử lý được, và Spring trả về 500 Internal Server Error. Đây là bug vì sai lệch semantics: downstream nói rõ "không tìm thấy user này" (404, lỗi đoán trước được do dữ liệu), nhưng client TaskFlow nhận "lỗi server không xác định" (500, ngụ ý TaskFlow tự hỏng) — log cũng không nói nguyên nhân thật là downstream trả 404.

Q3
Vì sao onStatus phải xử lý cả is4xxClientError lẫn is5xxServerError, thay vì chỉ một trong hai?

onStatus chỉ áp dụng cho predicate bạn khai — status code không khớp predicate nào thì rơi về hành vi mặc định, tức vẫn ném exception gốc của framework. Nếu chỉ đăng ký cho 4xx, lỗi 500 từ Profile Service (chính nó gặp sự cố) sẽ bỏ qua handler và rò thẳng lên controller — đúng vấn đề bài này giải quyết, chỉ khác mã lỗi. Muốn dịch nhất quán mọi lỗi downstream, phải đăng ký cho cả hai nhóm, hoặc gộp bằng HttpStatusCode::isError.

Q4
Bạn set connect timeout 2 giây nhưng quên set read timeout cho RestClient gọi Profile Service. Downstream bắt tay TCP bình thường nhưng transaction DB bị lock, không bao giờ trả response. Chuyện gì xảy ra?

Connect timeout chỉ canh bước bắt tay TCP — Profile Service vẫn nhận connection bình thường (chỉ xử lý xong không trả lời), nên bắt tay thành công trong tích tắc, connect timeout không có gì để can thiệp. Request rơi vào đúng tình huống "nhấc máy rồi im lặng" — không có read timeout nghĩa là chờ response vô thời hạn, y hệt kịch bản gây sự cố ở phần 1. Set một trong hai timeout không đủ; cả hai phải có mặt để bịt kín cả hai khoảnh khắc của lời gọi.

Q5
Vì sao WebClient không phải lựa chọn mặc định để thay RestTemplate trong một service TaskFlow đang chạy Spring MVC blocking như ở bài này?

WebClient là client reactive, trả về Mono/Flux — dùng đúng chỗ khi cả pipeline đã reactive (WebFlux), nơi non-blocking I/O tận dụng được để phục vụ nhiều request hơn trên ít thread hơn. TaskFlow chạy Spring MVC, mô hình blocking thread-per-request: gọi WebClient rồi block chờ kết quả không mang lại lợi ích reactive gì, chỉ thêm độ phức tạp API vào một pipeline vốn đã đồng bộ. RestClient giữ đúng mô hình quen thuộc, chỉ nâng cấp API.

Bài tiếp theo: nhiều downstream hơn thì viết RestClient tay bắt đầu lặp code — Bài 05 — HTTP Interface với @HttpExchange xoá đúng phần lặp đó.

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

@HttpExchange — khai báo interface, Spring sinh client