Gọi một dịch vụ HTTP giống như gọi điện cho một văn phòng khác. Có hai khoảng chờ tách biệt: chờ họ nhấc máy, và chờ cả cuộc nói chuyện kết thúc. Phần lớn người đặt giới hạn cho cái thứ nhất rồi quên cái thứ hai — và thế là một văn phòng nhấc máy xong ngồi im sẽ giữ đường dây của bạn mãi mãi. Đó là một trong ba mặc định lặng lẽ của HttpClient mà bài này muốn chỉ ra.

Trước Java 11, gọi một API HTTP bằng thư viện chuẩn nghĩa là HttpURLConnection — một API từ năm 1997, không hỗ trợ HTTP/2, không bất đồng bộ, và khó dùng tới mức gần như mọi dự án đều kéo thêm Apache HttpClient hoặc OkHttp vào chỉ để gọi vài endpoint. java.net.http.HttpClient thay hẳn chuyện đó.

Toàn bộ số liệu dưới đây đo trên một máy chủ HTTP chạy cục bộ trong cùng tiến trình (máy tôi), để kết quả tái lập được và không phụ thuộc mạng.

Gọi một cái GET

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

HttpResponse<String> r = client.send(
        HttpRequest.newBuilder(URI.create(goc + "/xin-chao")).build(),
        HttpResponse.BodyHandlers.ofString());
  mã trạng thái : 200
  content-type  : text/plain; charset=utf-8
  thân          : chào từ máy chủ
  phiên bản     : HTTP_1_1

Ba đối tượng, mỗi cái một việc: HttpClient giữ cấu hình và tài nguyên dùng chung, HttpRequest là một yêu cầu bất biến, HttpResponse là kết quả.

Chú ý followRedirects. Mặc định là NEVER — nghĩa là gặp 301 hay 302 thì bạn nhận về đúng mã đó với thân rỗng chứ không phải nội dung trang đích. Đây là mặc định lặng thứ nhất, vì hầu hết thư viện khác theo chuyển hướng sẵn. NORMAL theo chuyển hướng nhưng không hạ cấp từ HTTPS xuống HTTP — đó là lựa chọn đúng gần như mọi lúc.

POST và các kiểu thân yêu cầu

HttpRequest.newBuilder(URI.create(goc + "/vong-lai"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"ten\":\"Minh\"}"))
        .build();
  mã : 200
  máy chủ nhận được : {"ten":"Minh"}

BodyPublishers có sẵn ofString, ofFile, ofByteArray, ofInputStream và noBody. Chiều ngược lại, BodyHandlers có ofString, ofByteArray, ofLines (trả Stream<String>), discarding, và một cái rất tiện:

client.send(req, HttpResponse.BodyHandlers.ofFile(Path.of("tai-ve.txt")));
  ghi vào tai-ve.txt (21 byte)

Tải thẳng ra tệp, không qua bộ nhớ. Với tệp lớn thì đây là khác biệt giữa chạy được và OutOfMemoryError — cùng nguyên tắc "đừng nạp cả tệp vào RAM" ở bài về Files.lines.

Thứ không có sẵn là JSON. HttpClient không biết gì về JSON, nên bạn tự ghép với Jackson. Với tôi đó là thiết kế đúng: một lớp làm HTTP, một lớp làm JSON.

Hai loại timeout, và cái bạn hay quên

Đây là mặc định lặng thứ hai, và là chỗ nguy hiểm nhất trong bài.

HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5))   // thời gian BẮT TAY
HttpRequest.newBuilder(uri).timeout(Duration.ofMillis(300))     // thời gian CẢ LỜI GỌI

connectTimeout chỉ tính lúc mở kết nối TCP — chờ họ nhấc máy. Kết nối xong rồi mà máy chủ ngồi im mãi thì nó không có tác dụng gì cả.

Đặt timeout trên request:

  HttpTimeoutException: request timed out

Không đặt, với một endpoint ngủ 3 giây rồi mới trả lời:

  chờ 3.0 giây rồi mới xong

Ba giây ở đây là vì máy chủ thử nghiệm chỉ ngủ ba giây. Với một máy chủ thật bị treo, con số đó là vô hạn — luồng của bạn nằm đó cho tới khi hệ điều hành bỏ cuộc.

Luôn đặt timeout trên request, không chỉ connectTimeout trên client. Một dịch vụ bên thứ ba chậm mà không có phép chờ sẽ ăn dần hết luồng xử lý của bạn, và triệu chứng người dùng thấy không phải là "API kia hỏng" mà là "cả trang web của bạn không vào được".

Đây đúng là bài học đã ghi trong CLAUDE.md của chính blog này về SMTP_TIMEOUT_MS, gặp lại ở một giao thức khác.

Mã lỗi HTTP không phải ngoại lệ

Mặc định lặng thứ ba:

  gọi tới endpoint trả 404 -> không ném gì, statusCode = 404

send() chỉ ném ngoại lệ khi không hoàn thành được lời gọi: không phân giải được tên miền, không mở được kết nối, hết giờ chờ. Máy chủ trả lời 404, 500 hay 503 đều là lời gọi thành công — bạn đã hỏi, và bạn đã nhận được câu trả lời. Cái máy nhấc, có tiếng người, chỉ là người ta nói "không có".

Nên một đoạn try/catch quanh send() không hề bảo vệ bạn khỏi lỗi phía máy chủ. Phải kiểm mã trạng thái tường minh:

if (r.statusCode() / 100 != 2) {
    throw new IllegalStateException("API trả " + r.statusCode() + ": " + r.body());
}

Tôi khuyên gói lời gọi vào một phương thức nhỏ làm sẵn việc này, vì quên kiểm mã trạng thái là loại lỗi im lặng đúng kiểu khó chịu nhất: bạn đưa thân của một trang lỗi HTML vào bộ phân tích JSON, và thông báo lỗi cuối cùng chẳng liên quan gì tới nguyên nhân.

sendAsync: chỗ nó đáng giá nhất

Sáu lời gọi độc lập, mỗi lời gọi mất khoảng 200 ms:

  tuần tự (send)          :   1475 ms
  song song (sendAsync)   :    249 ms

Gần sáu lần. Và đây không phải chuyện tối ưu vi mô — nó là chuyện một trang hiển thị trong một phần tư giây thay vì một giây rưỡi.

Cách viết:

List<CompletableFuture<HttpResponse<String>>> fs = ds.stream()
        .map(u -> client.sendAsync(HttpRequest.newBuilder(u).build(),
                                   HttpResponse.BodyHandlers.ofString()))
        .toList();
CompletableFuture.allOf(fs.toArray(new CompletableFuture[0])).join();

Điểm cần để ý: .toList() phải nằm ở đó, và đây là cái bẫy tôi muốn nhấn mạnh nhất của mục này.

Stream xử lý từng phần tử đi hết chuỗi thao tác rồi mới sang phần tử sau. Nên nếu bạn viết liền một mạch:

ds.stream()
  .map(u -> client.sendAsync(...))
  .map(CompletableFuture::join)     // <- chờ ngay tại đây
  .toList();

thì phần tử thứ nhất được gửi đi rồi chờ xong, sau đó mới tới phần tử thứ hai. Cùng sáu lời gọi ấy:

  map(sendAsync).map(join)          : 1272 ms
  toList() rồi mới join             :  247 ms

Hơn năm lần, và mã chỉ khác nhau ở chỗ đặt một dấu chấm. Nó vẫn chạy đúng, vẫn trả về sáu kết quả, nên không có gì báo cho bạn biết phần bất đồng bộ đã bị vô hiệu hoàn toàn.

Quy tắc để nhớ: gửi hết trước, chờ sau. toList() ép gửi hết; chỉ khi đó join mới là chờ song song.

Xâu chuỗi và bắt lỗi:

client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
        .thenApply(HttpResponse::body)
        .thenApply(String::toUpperCase)
        .exceptionally(ex -> "hỏng: " + ex.getMessage())
        .join();
  CHÀO TỪ MÁY CHỦ

Nhưng phải biết mã của bạn đang chạy ở đâu:

  thenApply chạy trên luồng : ForkJoinPool.commonPool-worker-1

Mặc định các bước thenApply chạy trên pool chung của ForkJoin — cùng cái pool mà parallelStream dùng, và nó có số luồng bằng số nhân trừ một. Đặt một lời gọi chặn hay một truy vấn cơ sở dữ liệu vào đó là bạn đang lấy tài nguyên của toàn bộ ứng dụng. Cần việc nặng thì truyền executor riêng bằng thenApplyAsync(fn, executor).

Hoặc bỏ hẳn bất đồng bộ, dùng luồng ảo

Java 21 có luồng ảo, và nó làm cho phần lớn lý do dùng sendAsync biến mất:

try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
    for (URI u : ds) es.submit(() -> client.send(HttpRequest.newBuilder(u).build(), bh).body());
}
  6 lời gọi song song bằng luồng ảo : 47 ms

Cùng mức song song, nhưng mã là send() đồng bộ thẳng tuột — đọc từ trên xuống, try/catch hoạt động bình thường, dấu vết ngăn xếp khi lỗi chỉ về đúng dòng gây lỗi thay vì một mớ khung CompletableFuture.

Lời khuyên của tôi cho mã mới trên Java 21: viết send() đồng bộ, chạy trên luồng ảo. Để dành sendAsync cho lúc bạn thật sự cần ghép nối các bước bằng CompletableFuture, hoặc khi mã phải chạy trên bản Java cũ hơn.

Một client dùng chung cho cả ứng dụng

  luồng lúc đầu           : 6
  sau khi tạo 50 client   : 56

Đúng một luồng thêm cho mỗi HttpClient, cộng một pool kết nối riêng. Tạo client trong mỗi lần gọi là rò rỉ tài nguyên chậm rãi — và trên máy chủ có tải, đây là loại lỗi chỉ lộ ra sau vài ngày chạy.

Nên khai một lần và dùng lại. HttpClient an toàn luồng:

private static final HttpClient CLIENT = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

Java 21 bổ sung một thứ mà bốn phiên bản trước không có:

  AutoCloseable : true
  có phương thức close() : có

Từ Java 21, HttpClient cài AutoCloseable, nên dùng được trong try-với-tài-nguyên và giải phóng được luồng của nó. Trước đó, một client đã tạo sẽ giữ luồng cho tới khi bộ thu gom rác dọn, và không có cách nào yêu cầu nó dừng — một chi tiết gây phiền cho các ứng dụng tạo client theo từng phiên làm việc.

Với client dùng chung suốt vòng đời ứng dụng thì không cần đóng. Với client tạo tạm thì hãy đóng.

Khi nào vẫn nên dùng thư viện ngoài

HttpClient đủ cho việc gọi API, tải tệp, và phần lớn tích hợp. Nó hỗ trợ HTTP/2 với ghép kênh, WebSocket, xác thực, cookie.

Ba chỗ nó chưa bằng:

Thử lại tự động. Không có sẵn. Phải tự viết vòng lặp có lùi theo cấp số nhân, hoặc dùng Resilience4j.

Chặn giữa (interceptor). Không có khái niệm này, nên ghi log, thêm header xác thực hay đo thời gian đều phải tự gói lại trong phương thức của mình.

Đa phần biểu mẫu (multipart). Không có BodyPublisher sẵn — phải tự dựng phần thân với chuỗi ngăn cách. Làm được nhưng lắt nhắt; tải tệp lên thường xuyên thì OkHttp gọn hơn nhiều.

Còn ở tầng framework, Spring có RestClient và WebClient bọc bên ngoài, thêm ánh xạ JSON, chặn giữa và xử lý lỗi. Trong ứng dụng Spring thì dùng chúng; trong thư viện hay công cụ dòng lệnh, HttpClient trần là đủ và không kéo theo phụ thuộc nào.

Muốn tự soi trong ba mươi giây: tìm mọi chỗ gọi HTTP ra ngoài trong dự án, rồi kiểm hai thứ — có .timeout(...) trên request không (connectTimeout trên client không thay được nó), và có kiểm statusCode() không hay đang cho rằng send() trả về là mọi thứ ổn. Hai câu hỏi đó bắt được gần hết các sự cố "hệ thống chậm không rõ lý do" mà tôi từng phải đi tìm.

Mẫu số chung

Cái cú điện thoại tới một văn phòng khác — và ba mặc định lặng của nó — không phải chuyện riêng của Java. Mọi thư viện HTTP ở mọi ngôn ngữ đều bắt bạn đối diện đúng ba câu hỏi, và câu trả lời mặc định của chúng thường là cái bẫy. Go khét tiếng nhất: http.Client{} mặc định không có timeout nào cả — treo vô hạn đúng như HttpClient khi bạn quên .timeout(); lỗi chỉ trả về khi chuyến đi hỏng, còn 404/500 thì phải tự đọc resp.StatusCode; và một client dùng chung là cách làm chuẩn. Python requests cũng vậy: không truyền timeout= là chờ mãi, và raise_for_status() phải gọi tay vì mã lỗi không tự thành ngoại lệ. Node fetch bắt bạn tự kiểm response.ok và dùng AbortController để đặt hạn chờ.

Điểm chung, và là thứ đáng mang theo, gồm ba câu hỏi mà bạn phải trả lời tường minh ở mọi ngôn ngữ vì mặc định luôn đứng về phía "im lặng hỏng". Một — đặt hạn chờ cho cả lời gọi, không chỉ cho lúc kết nối: thiếu nó, một dịch vụ treo không làm API kia sập mà làm của bạn sập, vì luồng của bạn kẹt ở đó. Hai — một phản hồi lỗi vẫn là một lời gọi thành công: 4xx/5xx không ném ngoại lệ ở đâu cả, phải tự kiểm mã trạng thái, nếu không bạn nhét trang lỗi HTML vào bộ phân tích JSON. Ba — dựng client một lần rồi dùng lại, vì mỗi client mang theo pool kết nối và luồng riêng. Ai nhớ ba câu đó thì gọi HTTP an toàn ở bất kỳ ngôn ngữ nào — còn ai tin vào mặc định thì sớm muộn cũng gặp một cuộc gọi không bao giờ gác máy.

Ngày mai xuống một tầng nữa: JDBC — Connection, PreparedStatement, ResultSet, và vì sao nối chuỗi để dựng câu SQL là mời sẵn SQL injection vào nhà.