HttpClient trong .NET — Đừng new mỗi lần rồi tự bắn vào chân

Phong Hy

Có lần mình ngồi nhìn một service .NET chạy êm mấy tháng bỗng dưng trả 500 hàng loạt. Metric thì xanh, CPU thấp, database rảnh rang. Log chỉ lặp đi lặp lại đúng một dòng:

System.Net.Sockets.SocketException: Only one usage of each socket address
(protocol/network address/port) is normally permitted

Đọc qua thì tưởng lỗi hạ tầng, nhưng nguyên nhân thật nằm ở một dòng code mà rất nhiều người mới làm .NET từng viết: using var client = new HttpClient(); ngay trong hàm gọi API.

Tại sao new HttpClient() mỗi request lại giết service

HttpClient không phải một đối tượng nhẹ như bạn tưởng. Phía sau nó là HttpMessageHandler, và handler mới là thứ thật sự giữ connection pool, socket và TLS session. Khi bạn new HttpClient(), bạn tạo luôn một handler mới, và mỗi handler có pool riêng.

Ba hệ quả kéo theo:

  • Mỗi request mở connection TCP mới, thay vì tái dùng connection đang sống. TLS handshake (nhất là TLS 1.3 với round trip phụ) bị trả giá lại từ đầu cho mọi lời gọi.
  • Socket cũ không đóng ngay. Nó nằm ở trạng thái TIME_WAIT vài chục giây đến vài phút tuỳ hệ điều hành. Đủ nhanh và đủ nhiều request là hết cổng ephemeral — đúng cái thông báo lỗi ở trên.
  • HttpClient implement IDisposable nên ai cũng theo phản xạ bọc using cho "sạch". Ở đây phản xạ đúng lại thành sai.

Nói cách khác: bạn đang chạy một cái connection pool mới cho từng request, rồi vứt nó đi kèm một đống socket chưa kịp tắt.

Cách sửa: một client dùng chung, hoặc để factory lo

Cách đơn giản nhất là giữ một instance duy nhất, sống theo vòng đời app:

public sealed class GithubClient
{
    private static readonly HttpClient _http = new(new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        AutomaticDecompression   = DecompressionMethods.All
    })
    {
        BaseAddress = new Uri("https://api.github.com/"),
        Timeout     = TimeSpan.FromSeconds(10)
    };

    public Task<string> GetRepoAsync(string repo, CancellationToken ct)
        => _http.GetStringAsync($"repos/{repo}", ct);
}

Hai dòng trong SocketsHttpHandler đáng để ý:

  • PooledConnectionLifetime buộc connection được tái tạo định kỳ. Đây chính là thuốc chữa bệnh "DNS cũ": client static giữ IP đã resolve từ lúc khởi động, nên khi backend sau load balancer đổi IP, app vẫn nói chuyện với cái IP đã chết. Ai từng bị lỗi chỉ xảy ra sau khi deploy hạ tầng là gặp đúng ca này.
  • AutomaticDecompression để tầng socket tự xử lý gzip/brotli, đỡ phải nhớ trong từng chỗ gọi.

Nếu project đã có Dependency Injection, dùng IHttpClientFactory sẽ gọn hơn. Nó quản lý handler theo pool, xoay định kỳ, và cho phép gắn policy theo tên client:

services.AddHttpClient("github", c =>
{
    c.BaseAddress = new Uri("https://api.github.com/");
    c.Timeout     = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler();

AddStandardResilienceHandler (Microsoft.Extensions.Http.Resilience) đã gói sẵn retry, timeout, circuit breaker và rate limiter với cấu hình mặc định khá hợp lý. Trước đây mình hay tự viết Polly policy, giờ phần lớn trường hợp chỉ cần một dòng này là đủ.

Vài cái bẫy nhỏ nhưng hay đau

Đừng resolve client rồi cache trong singleton. IHttpClientFactory trả về HttpClient nhẹ, dùng rồi bỏ. Nếu bạn nhét nó vào một singleton sống mãi, bạn quay lại đúng vấn đề cũ, chỉ khác là khó thấy hơn.

Đừng set Timeout quá ngắn cùng lúc với retry. Timeout 2 giây cộng 3 lần retry là 6 giây chờ trước khi báo lỗi, mà nguyên nhân gốc thì vẫn nằm đó. Timeout nên theo SLA thật của phía bạn gọi.

Chú ý trần connection. SocketsHttpHandler.MaxConnectionsPerServer mặc định là không giới hạn. Với service nội bộ chỉ có vài pod, mở cả trăm connection song song từ một instance là cách nhanh nhất để tự làm nghẽn đối phương.

CancellationToken xuyên suốt. Request bị huỷ từ client mà không truyền token xuống thì connection vẫn chạy tới cùng rồi mới trả về, tốn tài nguyên vô ích. Đây là loại lỗi không crash gì cả nhưng làm latency trung bình xấu dần.

Bài học mình rút ra sau lần đó khá đơn giản: trong .NET, thứ implement IDisposable không mặc nhiên là thứ nên tạo mỗi lần dùng. HttpClient là ví dụ rõ nhất — nó giống một connection pool hơn là một request object.