DI029

HttpClient Lifetime Misuse

Two opposite lifetime errors on the same connection pool. Socket exhaustion — a registered service constructs new HttpClient(...) on a per-invocation path (a method, accessor, lambda, any loop body, or the constructor of a transient service). Stale DNS — an HttpClient is handed to the container as a singleton (AddSingleton<HttpClient>, AddSingleton(new HttpClient()), a singleton ServiceDescriptor, or a keyed singleton) or held in a static field or property.

Warning Default severity · Code fix: No

Why it matters

Each per-call client opens its own connection pool, and disposing it does not free the socket — the connection sits in TIME_WAIT for minutes, so under load the ephemeral port range runs out and the application fails with SocketException. Wrapping the construction in using makes it worse, not better, because disposal is exactly what strands the socket. The usual fix — make it a singleton — trades the problem for its mirror image: one handler holds its connections for the life of the process and never re-resolves DNS, so after a failover or deployment the client keeps routing to an endpoint that has moved. IHttpClientFactory is the only shape that gets both connection reuse and DNS freshness.

README problem example

services.AddScoped<ApiClient>();

public class ApiClient
{
    public async Task<Order> GetAsync(int id)
    {
        using var http = new HttpClient();  // DI029: one socket per call
        return await http.GetFromJsonAsync<Order>($"/orders/{id}");
    }
}

services.AddSingleton<HttpClient>();  // DI029: handler never rotates
services.AddScoped<HttpClient>();     // DI029: one handler pool per scope

README better pattern

services.AddHttpClient<ApiClient>(c => c.BaseAddress = new Uri("https://api.example.com"));

public class ApiClient
{
    private readonly HttpClient _http;

    public ApiClient(HttpClient http) => _http = http;

    public Task<Order> GetAsync(int id) => _http.GetFromJsonAsync<Order>($"/orders/{id}");
}

No — planned. Rewriting new HttpClient() into an injected IHttpClientFactory requires adding services.AddHttpClient() at a registration site that may be in another document or project, and possibly a PackageReference — which a code fix cannot do. Applying only the constructor and call-site half produces code that compiles and then throws InvalidOperationException: No service for type 'IHttpClientFactory'.

Guardrails

When DI029 stays silent

The socket-exhaustion tier fires only when the containing type is provably a registered implementation in the same compilation, so tests, Program/top-level statements, and unregistered helpers stay silent — a compilation with no registrations reports nothing at all. It also requires IHttpClientFactory to be available, so a diagnostic is never raised where the fix is unavailable. A handler supplied by the caller transfers pool ownership and stays silent, as does disposeHandler: false and any non-constant disposeHandler; handler arguments are bound by parameter symbol rather than position. A client stored in a member is judged against the owner's lifetime, since one pool shared by a singleton is correct while a transient owner rebuilds it per resolution. A bare handler construction stays silent: constructing HttpClientHandler or SocketsHttpHandler opens no connection until something sends through it, and the leak shape that matters — new HttpClient(new SocketsHttpHandler()) — is already reported through the client. A client whose handler sets PooledConnectionLifetime is also silent at both stale-DNS tiers: that handler retires pooled connections on an interval and re-resolves DNS with them, which is the documented way to run a long-lived client without the factory. Exact type-backed scoped self-bindings now report when IHttpClientFactory is available because each scope creates and disposes an independent handler pool; direct, keyed, and ServiceDescriptor forms share that boundary. Factory-backed scoped registrations, scoped HttpClient subclasses, and projects without the factory API remain conservative and silent. AddTransient<HttpClient> remains DI008's finding and is deliberately not double-reported. HttpClient subclasses are excluded at the singleton and static gates, Lazy<HttpClient> and dictionary-of-clients static wrappers are accepted false negatives, and a singleton factory that provably delegates to IHttpClientFactory.CreateClient stays silent. A single construction never yields two findings: static initializers belong to the static-member tier and an argument-position construction to the registration tier.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app socket-exhaustion warning

    public class Bad_HttpClientPerRequest
    {
        public async Task<string> GetAsync(string url)
        {
            // [DI029] One socket per call, stranded in TIME_WAIT after disposal. The using statement
            // is what causes the leak here, not what prevents it.
            using var http = new HttpClient();
            return await http.GetStringAsync(url);
        }
    }

Sample app injected-factory pattern

    public class Good_InjectedFactory
    {
        private readonly IHttpClientFactory _factory;

        public Good_InjectedFactory(IHttpClientFactory factory) => _factory = factory;

        public async Task<string> GetAsync(string url)
        {
            // The factory owns the handler pool and rotates it.
            var http = _factory.CreateClient("default");
            return await http.GetStringAsync(url);
        }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules