IHttpClientFactory and Typed Clients
Register named and typed clients with AddHttpClient, set base addresses and timeouts, and understand how the factory rotates handlers.
builder.Services.AddHttpClient<InventoryClient>(http =>
{
http.BaseAddress = new Uri("https://inventory.internal/v2/");
http.Timeout = TimeSpan.FromSeconds(5);
});IHttpClientFactory separates the cheap part of HTTP (the HttpClient object) from the expensive part (the handler chain and its connection pool). AddHttpClient registers a configuration under a name. Each CreateClient(name) returns a new HttpClient wrapped around a cached handler chain for that name, so creating clients freely is fine. Disposing one is optional and harmless, because the client does not own the pooled handler; what you must not do is cache a single client for a long time, which stops handler rotation.
Handler lifetime and rotation
A handler chain is reused for HandlerLifetime, 2 minutes by default (SetHandlerLifetime changes it). After that, new clients get a fresh chain and the old one is disposed once no client references it. The primary handler is a SocketsHttpHandler whose PooledConnectionLifetime is set to the same value, so connections recycle and DNS changes are seen within that window. Each chain also includes logging handlers: by default every request writes four Information-level entries, a start and an end from the LogicalHandler and from the ClientHandler, under System.Net.Http.HttpClient.{name}.
Three registration styles
| Style | Registration | Consumed as | Use when |
|---|---|---|---|
| Named | AddHttpClient("payments", ...) | factory.CreateClient("payments") | The client is used in one or two places, or chosen at runtime |
| Typed | AddHttpClient<InventoryClient>(...) | Inject InventoryClient | Most APIs: one class owns the URLs, DTOs and error mapping |
| Keyed | .AddAsKeyed() on a named client | [FromKeyedServices("payments")] HttpClient | You want a plain HttpClient injected without a wrapper class |
A typed client is registered as transient: the factory builds an HttpClient and passes it to the constructor. Its name is the type name, so AddHttpClient<InventoryClient>() called twice configures the same client, which is how tests override it.
BaseAddress and Timeout
Read the base URL from configuration, end it with a slash, and use relative paths without a leading slash. HttpClient.Timeout defaults to 100 seconds, far too long for an API that must answer its own caller in a few seconds. Set it per client, or let a resilience handler own timeouts and keep Timeout above its total timeout so the two do not fight.
The production failure mode: a typed client captured by a singleton
Inject a typed client into a singleton (a BackgroundService, a cache warmer) and that singleton holds one HttpClient forever. The factory can no longer rotate its handler, so you are back to the static-client DNS problem, and the scope validation in Development will not warn you because typed clients are transient, not scoped. Either inject IHttpClientFactory and create a client per unit of work, or give that client a SocketsHttpHandler with its own PooledConnectionLifetime via UseSocketsHttpHandler and an infinite handler lifetime.
Another frequent surprise: handlers added with AddHttpMessageHandler live as long as the chain, in a DI scope the factory creates, not the scope of the current HTTP request. A scoped service injected into a handler is not the one your endpoint sees.
Example
When to use it
- Give the orders API a typed InventoryClient whose base URL comes from configuration and whose timeout is 5 seconds instead of 100.
- Poll a carrier tracking API from a BackgroundService without pinning one HttpClient for the life of the pod.
- Drop the four per-request Information entries the factory writes for every outbound call while keeping its warnings and errors.
More examples
A worker that must not capture a typed client
A <code>BackgroundService</code> is a singleton. Asking for a typed client in its constructor would freeze one <code>HttpClient</code> for the life of the process. Calling <code>CreateClient</code> on each tick is cheap and keeps handler rotation intact. The catch takes every exception except shutdown: an unhandled exception in a <code>BackgroundService</code> stops the whole host by default (<code>BackgroundServiceExceptionBehavior.StopHost</code>), so one carrier timeout must not escape <code>ExecuteAsync</code>. The alternative, when you want to keep the typed client, is <code>.UseSocketsHttpHandler((h, _) => h.PooledConnectionLifetime = TimeSpan.FromMinutes(2)).SetHandlerLifetime(Timeout.InfiniteTimeSpan)</code>.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient("carrier-tracking", http =>
{
http.BaseAddress = new Uri("https://tracking.carrier.example/api/");
http.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddHostedService<ShipmentPoller>();
builder.Build().Run();
public sealed class ShipmentPoller(IHttpClientFactory factory, ILogger<ShipmentPoller> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// A new client per tick picks up the current handler, so rotation keeps working.
var http = factory.CreateClient("carrier-tracking");
try
{
var updates = await http.GetFromJsonAsync<TrackingUpdate[]>("updates?since=5m", stoppingToken);
logger.LogInformation("Received {Count} tracking updates", updates?.Length ?? 0);
}
// HttpClient.Timeout surfaces as TaskCanceledException and a bad body as JsonException;
// only shutdown may end the loop.
catch (Exception ex) when (ex is not OperationCanceledException || !stoppingToken.IsCancellationRequested)
{
logger.LogWarning(ex, "Carrier tracking poll failed");
}
}
}
}
public sealed record TrackingUpdate(string ShipmentId, string Status, DateTimeOffset At);
Quieter HttpClient logging in appsettings.json
The factory's logging handlers write under <code>System.Net.Http.HttpClient.{ClientName}.LogicalHandler</code> (start and end of the logical call) and <code>.ClientHandler</code> (each attempt sent and its response headers received), so one prefix filter silences all of them, or a longer key targets one client. In code, <code>.RemoveAllLoggers()</code> on a client builder removes the handlers entirely, which is better when you emit your own structured logs or rely on OpenTelemetry spans.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
// Four Information entries per outbound request otherwise; keep warnings and errors.
"System.Net.Http.HttpClient": "Warning"
}
},
"Inventory": {
"BaseUrl": "https://inventory.shop.internal/v2/"
},
"Payments": {
"BaseUrl": "https://payments.shop.internal/"
}
}Keyed HttpClient injection
<code>AddAsKeyed()</code> registers the named client as a keyed service, scoped by default, so an endpoint can take the configured <code>HttpClient</code> directly. It avoids writing a wrapper class for a client used in one place. For anything with more than one call site, a typed client is still better because the URLs and DTOs live in one class.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("fraud-check", http =>
{
http.BaseAddress = new Uri("https://fraud.shop.internal/");
http.Timeout = TimeSpan.FromSeconds(2);
})
.AddAsKeyed();
var app = builder.Build();
app.MapPost("/payments/{paymentId}/screen", async (
string paymentId,
[FromKeyedServices("fraud-check")] HttpClient fraud,
CancellationToken ct) =>
{
using var response = await fraud.PostAsJsonAsync("screenings", new { paymentId }, ct);
return response.IsSuccessStatusCode
? Results.Ok(await response.Content.ReadFromJsonAsync<ScreeningResult>(ct))
: Results.Problem(statusCode: StatusCodes.Status502BadGateway, title: "Fraud service unavailable");
});
app.Run();
record ScreeningResult(string PaymentId, string Decision, int Score);
Discussion