IHttpClientFactory and Typed Clients

Register named and typed clients with AddHttpClient, set base addresses and timeouts, and understand how the factory rotates handlers.

Syntaxbuilder.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

StyleRegistrationConsumed asUse when
NamedAddHttpClient("payments", ...)factory.CreateClient("payments")The client is used in one or two places, or chosen at runtime
TypedAddHttpClient<InventoryClient>(...)Inject InventoryClientMost APIs: one class owns the URLs, DTOs and error mapping
Keyed.AddAsKeyed() on a named client[FromKeyedServices("payments")] HttpClientYou 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

Try it yourself
Loading editor…
Press Run to execute the code.

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, _) =&gt; h.PooledConnectionLifetime = TimeSpan.FromMinutes(2)).SetHandlerLifetime(Timeout.InfiniteTimeSpan)</code>.

Example · csharp
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.

Example · json
{
  "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.

Example · csharp
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

  • Be the first to comment on this lesson.