← System Design
Programming / System DesignExploring

Calling third-party APIs properly

The HttpClient Trap and Why IHttpClientFactory is Essential

httpclientdotnetapis

Gemini Generated Image

Gemini Generated

Context: Why I Looked Into This

I realized I had a dangerous blind spot: I was instinctively treating HttpClient like an ordinary disposable object—instantiating it with new HttpClient() inside a using block.

I learned this isn't a mere style preference; it's a guaranteed production bug under load. Here is why the "obvious" approach breaks and what I need to do instead.

Part 1: The Naive Way (And Why It Breaks)

This is the standard code pattern I was using. It looks correct, but it’s structurally flawed for high-traffic environments.

public class NaiveWeatherService
{
    public async Task<string> GetWeatherDataAsync(double lat, double lon)
    {
        // Issue: This 'using' block is the hidden trap
        using (var client = new HttpClient())
        {
            var url = $"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current=temperature_2m";
            var response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }
}

The Two Traps

  1. Trap 1: Socket Exhaustion. When you dispose of HttpClient, the underlying TCP socket is not released immediately. The Operating System keeps it in a TIME_WAIT state for several minutes to ensure all packets are delivered.The Crash: Under high concurrency, you will create new sockets faster than old ones clear TIME_WAIT. You will exhaust all available outbound ports, causing your app to throw a SocketException.
  2. Trap 2: The "Fix" That Creates the DNS Bug. To solve exhaustion, the immediate thought is to make HttpClient a static singleton so you reuse one socket forever.The Crash: Reusing a single connection forever binds it to the initial DNS resolution result. If the external API updates its IP address (e.g., during a failover or redeploy), your app will continue hitting the old, now dead, IP address until the process is restarted.

Part 2: Why IHttpClientFactory is the Solution

IHttpClientFactory solves both traps by decoupling the HttpClient instance from the underlying connection handler.

This is the key insight for me: The factory manages a pool of HttpMessageHandlers (the objects that actually handle the TCP sockets). It performs two critical roles:

  1. Fixes Exhaustion: It creates lightweight HttpClient shells that pick up an available, pre-warmed connection handler from the pool, reusing the existing connection efficiently.
  2. Fixes DNS Staleness: It rotates handlers on a timer (default: 2 minutes). When a handler is rotated, it is retired from active service (allowing its socket to close naturally) and the next request gets a fresh handler that triggers a new DNS lookup.

It’s not abstraction for its own sake; it's the required mechanism to safely handle external HTTP requests in .NET.

Part 3: Implementation Patterns

  1. Basic factory — fine for a genuine one-off call, nothing to configure:
builder.Services.AddHttpClient();
 
var client = _factory.CreateClient();
var data = await client.GetStringAsync("https://api.example.com/data");
  1. Named clients — shared config (base URL, headers) across multiple call sites, referenced by string:
builder.Services.AddHttpClient("OpenMeteo", client =>
{
    client.BaseAddress = new Uri("https://api.open-meteo.com/");
    client.Timeout = TimeSpan.FromSeconds(10);
});
 
var client = _factory.CreateClient("OpenMeteo");
  1. Typed clients — what I actually use. A dedicated class per external API, interface-based, fully mockable in tests, no magic strings to typo:
public interface IWeatherApiClient
{
    Task<OpenMeteoResponseDto?> GetCurrentForecastAsync(double latitude, double longitude, CancellationToken ct = default);
}
 
public class WeatherApiClient : IWeatherApiClient
{
    private readonly HttpClient _httpClient;
    public WeatherApiClient(HttpClient httpClient) => _httpClient = httpClient;
 
    public async Task<OpenMeteoResponseDto?> GetCurrentForecastAsync(double latitude, double longitude, CancellationToken ct = default)
    {
        var endpoint = $"v1/forecast?latitude={latitude}&longitude={longitude}&current=temperature_2m";
        return await _httpClient.GetFromJsonAsync<OpenMeteoResponseDto>(endpoint, ct);
    }
}
 
builder.Services.AddHttpClient<IWeatherApiClient, WeatherApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.open-meteo.com/");
    client.Timeout = TimeSpan.FromSeconds(10);
});

Part 4: Advanced Scenario: The DB-Configured API Anti-Pattern

I have not built this yet, but I need to avoid a massive performance mistake documented in some reference material.

The Mistake: Querying a configuration database (for things like API URL or Token) on every single outbound API call.

// !!! DON'T DO THIS DIRECTLY !!!
public async Task<GitHubUserDto?> GetUserProfileAsync(string username, CancellationToken ct)
{
    // High Latency Trap: DB call on every single outbound request
    var config = await _configRepository.GetActiveConfigByNameAsync("default", ct);
    // ... use config ...
}
 

The Problem: Doing a DB round-trip on every call adds significant latency to your hottest code path, just to read configuration values that change perhaps once a month.

The Correction: Wrap the configuration repository behind a short-TTL cache (e.g., IMemoryCache with a 2-5 minute lifespan).

Part 5: Mandatory Review Checklist for All HTTP Requests

Keep these requirements in mind for every HTTP implementation, even the simple ones:

  1. Pass CancellationToken. Always pass the CancellationToken through to GetAsync or GetFromJsonAsync. If the calling user aborts, the outbound request must cancel server-side to save resources.
  2. Deserialize Directly from Stream. Use GetFromJsonAsync or ReadFromJsonAsync. Do not use ReadAsStringAsync followed by manual deserialization, as this allocates a massive, temporary string in memory before deserialization even begins.
  3. No dynamic or JObject. Create explicit DTOs. This allows Swagger/OpenAPI generation to work and keeps the compiler checking my code.
  4. Zero-plaintext Secrets. Never log API tokens or credentials in plaintext. (This is a quick debugging habit that I must actively avoid).

Sources & further reading