แก้ไข

แชร์ผ่าน


Common IHttpClientFactory usage issues

In this article, you'll learn some of the most common problems you can run into when using IHttpClientFactory to create HttpClient instances.

IHttpClientFactory is a convenient way to set up multiple HttpClient configurations in the DI container, configure logging, set up resilience strategies, and more. IHttpClientFactory also encapsulates the lifetime management of HttpClient and HttpMessageHandler instances, to prevent problems like socket exhaustion and losing DNS changes. For an overview on how to use IHttpClientFactory in your .NET application, see IHttpClientFactory with .NET.

Due to a complex nature of IHttpClientFactory integration with DI, you can hit some issues that might be hard to catch and troubleshoot. The scenarios listed in this article also contain recommendations, which you can apply proactively to avoid potential problems.

HttpClient doesn't respect Scoped lifetime

You can hit a problem if you need to access any scoped service, for example, HttpContext, or some scoped cache, from within the HttpMessageHandler. The data saved there can either "disappear", or, the other way around, "persist" when it shouldn't. This is caused by the Dependency Injection (DI) scope mismatch between the application context and the handler instance, and it's a known limitation in IHttpClientFactory.

IHttpClientFactory creates a separate DI scope per each HttpMessageHandler instance. These handler scopes are distinct from application context scopes (for example, ASP.NET Core incoming request scope, or a user-created manual DI scope), so they will not share scoped service instances.

As a result of this limitation:

  • Any data cached "externally" in a scoped service will not be available within the HttpMessageHandler.
  • Any data cached "internally" within the HttpMessageHandler or its scoped dependencies can be observed from multiple application DI scopes (for example, from different incoming requests) as they can share the same handler.

Consider the following recommendations to help alleviate this known limitation:

❌ DO NOT cache any scope-related information (such as data from HttpContext) inside HttpMessageHandler instances or its dependencies to avoid leaking sensitive information.

❌ DO NOT use cookies, as the CookieContainer will be shared together with the handler.

✔️ CONSIDER not storing the information, or only pass it within the HttpRequestMessage instance.

To pass arbitrary information alongside the HttpRequestMessage, you can use the HttpRequestMessage.Options property.

✔️ CONSIDER encapsulating all the scope-related (for example, authentication) logic in a separate DelegatingHandler that's not created by the IHttpClientFactory, and use it to wrap the IHttpClientFactory-created handler.

To create just an HttpMessageHandler without HttpClient, call IHttpMessageHandlerFactory.CreateHandler for any registered named client. In that case, you will need to create an HttpClient instance yourself using the combined handler. You can find a fully runnable example for this workaround on GitHub.

For more information, see the Message Handler Scopes in IHttpClientFactory section in the IHttpClientFactory guidelines.

HttpClient doesn't respect DNS changes

Even if IHttpClientFactory is used, it's still possible to hit the stale DNS problem. This can usually happen if an HttpClient instance gets captured in a Singleton service, or, in general, stored somewhere for a period of time that's longer than the specified HandlerLifetime. HttpClient will also get captured if the respective typed client is captured by a singleton.

❌ DO NOT cache HttpClient instances created by IHttpClientFactory for prolonged periods of time.

❌ DO NOT inject typed client instances into Singleton services.

✔️ CONSIDER requesting a client from IHttpClientFactory in a timely manner or each time you need one. Factory-created clients are safe to dispose.

HttpClient instances created by IHttpClientFactory are intended to be short-lived.

  • Recycling and recreating HttpMessageHandler's when their lifetime expires is essential for IHttpClientFactory to ensure the handlers react to DNS changes. HttpClient is tied to a specific handler instance upon its creation, so new HttpClient instances should be requested in a timely manner to ensure the client will get the updated handler.

  • Disposing of such HttpClient instances created by the factory will not lead to socket exhaustion, as its disposal does not trigger disposal of the HttpMessageHandler. IHttpClientFactory tracks and disposes of resources used to create HttpClient instances, specifically the HttpMessageHandler instances, as soon their lifetime expires and there's no HttpClient using them anymore.

Typed clients are intended to be short-lived as well, as an HttpClient instance is injected into the constructor, so it will share the typed client lifetime.

For more information, see the HttpClient lifetime management and Avoid typed clients in singleton services sections in the IHttpClientFactory guidelines.

HttpClient uses too many sockets

Even if IHttpClientFactory is used, it's still possible to hit the socket exhaustion issue with a specific usage scenario. By default, HttpClient doesn't limit the number of concurrent requests. If a large number of HTTP/1.1 requests are started concurrently at the same time, each of them will end up triggering a new HTTP connection attempt, because there is no free connection in the pool and no limit is set.

❌ DO NOT start a large number of HTTP/1.1 requests concurrently at the same time without specifying the limits.

✔️ CONSIDER setting HttpClientHandler.MaxConnectionsPerServer (or SocketsHttpHandler.MaxConnectionsPerServer, if you use it as a primary handler) to a reasonable value. Note that these limits only apply to the specific handler instance.

✔️ CONSIDER using HTTP/2, which allows multiplexing requests over a single TCP connection.

Typed client has the wrong HttpClient injected

There can be various situations in which it is possible to get an unexpected HttpClient injected into a typed client. Most of the time, the root cause will be in an erroneous configuration, as, by DI design, any subsequent registration of a service overrides the previous one.

Typed clients use named clients "under the hood": adding a typed client implicitly registers and links it to a named client. The client name, unless explicitly provided, will be set to the type name of TClient. This would be the first one from the TClient,TImplementation pair if AddHttpClient<TClient,TImplementation> overloads are used.

Therefore, registering a typed client does two separate things:

  1. Registers a named client (in a simple default case, the name is typeof(TClient).Name).
  2. Registers a Transient service using the TClient or TClient,TImplementation provided.

The following two statements are technically the same:

services.AddHttpClient<ExampleClient>(c => c.BaseAddress = new Uri("http://example.com"));

// -OR-

services.AddHttpClient(nameof(ExampleClient), c => c.BaseAddress = new Uri("http://example.com")) // register named client
    .AddTypedClient<ExampleClient>(); // link the named client to a typed client

In a simple case, it will also be similar to the following:

services.AddHttpClient(nameof(ExampleClient), c => c.BaseAddress = new Uri("http://example.com")); // register named client

// register plain Transient service and link it to the named client
services.AddTransient<ExampleClient>(s =>
    new ExampleClient(
        s.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(ExampleClient))));

Consider the following examples of how the link between typed and named clients can get broken.

Typed client is registered a second time

❌ DO NOT register the typed client separately — it is already registered automatically by the AddHttpClient<T> call.

If a typed client is erroneously registered a second time as a plain Transient service, this will overwrite the registration added by the HttpClientFactory, breaking the link to the named client. It will manifest as if the HttpClient's configuration is lost, as an unconfigured HttpClient will get injected into the typed client instead.

It might be confusing that, instead of throwing an exception, a "wrong" HttpClient is used. This happens because the "default" unconfigured HttpClient — the client with the Options.DefaultName name (string.Empty) — is registered as a plain Transient service, to enable the most basic HttpClientFactory usage scenario. That's why after the link gets broken and the typed client becomes just an ordinary service, this "default" HttpClient will naturally get injected into the respective constructor parameter.

Different typed clients are registered on a common interface

In case two different typed clients are registered on a common interface, they both would reuse the same named client. This can seem like the first typed client getting the second named client "wrongly" injected.

❌ DO NOT register multiple typed clients on a single interface without explicitly specifying the name.

✔️ CONSIDER registering and configuring a named client separately, and then linking it to one or multiple typed clients, either by specifying the name in AddHttpClient<T> call or by calling AddTypedClient during the named client setup.

By design, registering and configuring a named client with the same name several times just appends the configuration actions to the list of existing ones. This behavior of HttpClientFactory might not be obvious, but it is the same approach that is used by the Options pattern and configuration APIs like Configure.

This is mostly useful for advanced handler configurations, for example, adding a custom handler to a named client defined externally, or mocking a primary handler for tests, but it works for HttpClient instance configuration as well. For example, the three following examples will result in an HttpClient configured in the same way (both BaseAddress and DefaultRequestHeaders are set):

// one configuration callback
services.AddHttpClient("example", c =>
    {
        c.BaseAddress = new Uri("http://example.com");
        c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0");
    });

// -OR-

// two configuration callbacks
services.AddHttpClient("example", c => c.BaseAddress = new Uri("http://example.com"))
    .ConfigureHttpClient(c => c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0"));

// -OR-

// two configuration callbacks in separate AddHttpClient calls
services.AddHttpClient("example", c => c.BaseAddress = new Uri("http://example.com"));
services.AddHttpClient("example")
    .ConfigureHttpClient(c => c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0"));

This enables linking a typed client to an already defined named client, and also linking several typed clients to a single named client. It is more obvious when overloads with a name parameter are used:

services.AddHttpClient("LogClient", c => c.BaseAddress = new Uri(LogServerAddress));

services.AddHttpClient<FooLogger>("LogClient");
services.AddHttpClient<BarLogger>("LogClient");

The same thing can also be achieved by calling AddTypedClient during the named client configuration:

services.AddHttpClient("LogClient", c => c.BaseAddress = new Uri(LogServerAddress))
    .AddTypedClient<FooLogger>()
    .AddTypedClient<BarLogger>();

However, if you don't want to reuse the same named client, you but you still wish to register the clients on the same interface, you can do so by explicitly specifying different names for them:

services.AddHttpClient<ITypedClient, ExampleClient>(nameof(ExampleClient),
    c => c.BaseAddress = new Uri("http://example.com"));
services.AddHttpClient<ITypedClient, GithubClient>(nameof(GithubClient),
    c => c.BaseAddress = new Uri("https://github.com"));

See also