Calling third-party APIs properly
The HttpClient Trap and Why IHttpClientFactory is Essential

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}¤t=temperature_2m";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
}The Two Traps
- Trap 1: Socket Exhaustion. When you dispose of
HttpClient, the underlying TCP socket is not released immediately. The Operating System keeps it in aTIME_WAITstate for several minutes to ensure all packets are delivered.The Crash: Under high concurrency, you will create new sockets faster than old ones clearTIME_WAIT. You will exhaust all available outbound ports, causing your app to throw aSocketException. - Trap 2: The "Fix" That Creates the DNS Bug. To solve exhaustion, the immediate thought is to make
HttpClienta 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:
- Fixes Exhaustion: It creates lightweight
HttpClientshells that pick up an available, pre-warmed connection handler from the pool, reusing the existing connection efficiently. - 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
- 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");- 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");- 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}¤t=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).
- Hit the database only on a cache miss.
- Trading sub-second propagation (e.g., waiting 2 minutes for a new Base URL to take effect) is a perfect trade-off to remove a DB call from your hot path.
Part 5: Mandatory Review Checklist for All HTTP Requests
Keep these requirements in mind for every HTTP implementation, even the simple ones:
- Pass CancellationToken. Always pass the
CancellationTokenthrough toGetAsyncorGetFromJsonAsync. If the calling user aborts, the outbound request must cancel server-side to save resources. - Deserialize Directly from Stream. Use
GetFromJsonAsyncorReadFromJsonAsync. Do not useReadAsStringAsyncfollowed by manual deserialization, as this allocates a massive, temporary string in memory before deserialization even begins. - No
dynamicorJObject. Create explicit DTOs. This allows Swagger/OpenAPI generation to work and keeps the compiler checking my code. - Zero-plaintext Secrets. Never log API tokens or credentials in plaintext. (This is a quick debugging habit that I must actively avoid).