IHttpClientFactory with .NET

In this article, you'll learn how to use the IHttpClientFactory to create HttpClient types with various .NET fundamentals, such as dependency injection (DI), logging, and configuration. The HttpClient type was introduced in .NET Framework 4.5, which was released in 2012. In other words, it's been around for a while. HttpClient is used for making HTTP requests and handling HTTP responses from web resources identified by a Uri. The HTTP protocol makes up the vast majority of all internet traffic.

With modern application development principles driving best practices, the IHttpClientFactory serves as a factory abstraction that can create HttpClient instances with custom configurations. IHttpClientFactory was introduced in .NET Core 2.1. Common HTTP-based .NET workloads can take advantage of resilient and transient-fault-handling third-party middleware with ease.

Note

If your app requires cookies, it might be better to avoid using IHttpClientFactory in your app. For alternative ways of managing clients, see Guidelines for using HTTP clients.

Important

Lifetime management of HttpClient instances created by IHttpClientFactory is completely different from instances created manually. The strategies are to use either short-lived clients created by IHttpClientFactory or long-lived clients with PooledConnectionLifetime set up. For more information, see the HttpClient lifetime management section and Guidelines for using HTTP clients.

The IHttpClientFactory type

All of the sample source code in this article relies on the Microsoft.Extensions.Http NuGet package. Additionally, HTTP GET requests are made to the free {JSON} Placeholder API to get user Todo objects.

When you call any of the AddHttpClient extension methods, you're adding the IHttpClientFactory and related services to the IServiceCollection. The IHttpClientFactory type offers the following benefits:

  • Exposes the HttpClient class as a DI-ready type.
  • Provides a central location for naming and configuring logical HttpClient instances.
  • Codifies the concept of outgoing middleware via delegating handlers in HttpClient.
  • Provides extension methods for Polly-based middleware to take advantage of delegating handlers in HttpClient.
  • Manages the caching and lifetime of underlying HttpClientHandler instances. Automatic management avoids common Domain Name System (DNS) problems that occur when manually managing HttpClient lifetimes.
  • Adds a configurable logging experience (via ILogger) for all requests sent through clients created by the factory.

Consumption patterns

There are several ways IHttpClientFactory can be used in an app:

The best approach depends upon the app's requirements.

Basic usage

To register the IHttpClientFactory, call AddHttpClient:

using Shared;
using BasicHttp.Example;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHttpClient();
builder.Services.AddTransient<TodoService>();

using IHost host = builder.Build();

Consuming services can require the IHttpClientFactory as a constructor parameter with DI. The following code uses IHttpClientFactory to create an HttpClient instance:

using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shared;

namespace BasicHttp.Example;

public sealed class TodoService(
    IHttpClientFactory httpClientFactory,
    ILogger<TodoService> logger)
{
    public async Task<Todo[]> GetUserTodosAsync(int userId)
    {
        // Create the client
        using HttpClient client = httpClientFactory.CreateClient();
        
        try
        {
            // Make HTTP GET request
            // Parse JSON response deserialize into Todo types
            Todo[]? todos = await client.GetFromJsonAsync<Todo[]>(
                $"https://jsonplaceholder.typicode.com/todos?userId={userId}",
                new JsonSerializerOptions(JsonSerializerDefaults.Web));

            return todos ?? [];
        }
        catch (Exception ex)
        {
            logger.LogError("Error getting something fun to say: {Error}", ex);
        }

        return [];
    }
}

Using IHttpClientFactory like in the preceding example is a good way to refactor an existing app. It has no impact on how HttpClient is used. In places where HttpClient instances are created in an existing app, replace those occurrences with calls to CreateClient.

Named clients

Named clients are a good choice when:

  • The app requires many distinct uses of HttpClient.
  • Many HttpClient instances have different configurations.

Configuration for a named HttpClient can be specified during registration on the IServiceCollection:

using Shared;
using NamedHttp.Example;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

string? httpClientName = builder.Configuration["TodoHttpClientName"];
ArgumentException.ThrowIfNullOrEmpty(httpClientName);

builder.Services.AddHttpClient(
    httpClientName,
    client =>
    {
        // Set the base address of the named client.
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");

        // Add a user-agent default request header.
        client.DefaultRequestHeaders.UserAgent.ParseAdd("dotnet-docs");
    });

In the preceding code, the client is configured with:

  • A name that's pulled from the configuration under the "TodoHttpClientName".
  • The base address https://jsonplaceholder.typicode.com/.
  • A "User-Agent" header.

You can use configuration to specify HTTP client names, which is helpful to avoid misnaming clients when adding and creating. In this example, the appsettings.json file is used to configure the HTTP client name:

{
    "TodoHttpClientName": "JsonPlaceholderApi"
}

It's easy to extend this configuration and store more details about how you'd like your HTTP client to function. For more information, see Configuration in .NET.

Create client

Each time CreateClient is called:

  • A new instance of HttpClient is created.
  • The configuration action is called.

To create a named client, pass its name into CreateClient:

using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Shared;

namespace NamedHttp.Example;

public sealed class TodoService
{
    private readonly IHttpClientFactory _httpClientFactory = null!;
    private readonly IConfiguration _configuration = null!;
    private readonly ILogger<TodoService> _logger = null!;

    public TodoService(
        IHttpClientFactory httpClientFactory,
        IConfiguration configuration,
        ILogger<TodoService> logger) =>
        (_httpClientFactory, _configuration, _logger) =
            (httpClientFactory, configuration, logger);

    public async Task<Todo[]> GetUserTodosAsync(int userId)
    {
        // Create the client
        string? httpClientName = _configuration["TodoHttpClientName"];
        using HttpClient client = _httpClientFactory.CreateClient(httpClientName ?? "");

        try
        {
            // Make HTTP GET request
            // Parse JSON response deserialize into Todo type
            Todo[]? todos = await client.GetFromJsonAsync<Todo[]>(
                $"todos?userId={userId}",
                new JsonSerializerOptions(JsonSerializerDefaults.Web));

            return todos ?? [];
        }
        catch (Exception ex)
        {
            _logger.LogError("Error getting something fun to say: {Error}", ex);
        }

        return [];
    }
}

In the preceding code, the HTTP request doesn't need to specify a hostname. The code can pass just the path, since the base address configured for the client is used.

Typed clients

Typed clients:

  • Provide the same capabilities as named clients without the need to use strings as keys.
  • Provide IntelliSense and compiler help when consuming clients.
  • Provide a single location to configure and interact with a particular HttpClient. For example, a single typed client might be used:
    • For a single backend endpoint.
    • To encapsulate all logic dealing with the endpoint.
  • Work with DI and can be injected where required in the app.

A typed client accepts an HttpClient parameter in its constructor:

using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shared;

namespace TypedHttp.Example;

public sealed class TodoService(
    HttpClient httpClient,
    ILogger<TodoService> logger) : IDisposable
{
    public async Task<Todo[]> GetUserTodosAsync(int userId)
    {
        try
        {
            // Make HTTP GET request
            // Parse JSON response deserialize into Todo type
            Todo[]? todos = await httpClient.GetFromJsonAsync<Todo[]>(
                $"todos?userId={userId}",
                new JsonSerializerOptions(JsonSerializerDefaults.Web));

            return todos ?? [];
        }
        catch (Exception ex)
        {
            logger.LogError("Error getting something fun to say: {Error}", ex);
        }

        return [];
    }

    public void Dispose() => httpClient?.Dispose();
}

In the preceding code:

  • The configuration is set when the typed client is added to the service collection.
  • The HttpClient is assigned as a class-scoped variable (field), and used with exposed APIs.

API-specific methods can be created that expose HttpClient functionality. For example, the GetUserTodosAsync method encapsulates code to retrieve user-specific Todo objects.

The following code calls AddHttpClient to register a typed client class:

using Shared;
using TypedHttp.Example;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHttpClient<TodoService>(
    client =>
    {
        // Set the base address of the typed client.
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");

        // Add a user-agent default request header.
        client.DefaultRequestHeaders.UserAgent.ParseAdd("dotnet-docs");
    });

The typed client is registered as transient with DI. In the preceding code, AddHttpClient registers TodoService as a transient service. This registration uses a factory method to:

  1. Create an instance of HttpClient.
  2. Create an instance of TodoService, passing in the instance of HttpClient to its constructor.

Important

Using typed clients in singleton services can be dangerous. For more information, see the Avoid Typed clients in singleton services section.

Note

When registering a typed client with the AddHttpClient<TClient> method, the TClient type must have a constructor that accepts an HttpClient parameter. Additionally, the TClient type shouldn't be registered with the DI container separately.

Named and typed clients

Named clients and typed clients have their own strengths and weaknesses. There is a way to combine these two client types to get the best of both worlds.

The primary use case is the following: Use the same typed client but against different domains. For example you have a primary and a secondary service and they expose the exact same functionality. That means you can use the same typed client to wrap the HttpClient usage to issue requests, process responses, and handle errors. The exact same code will be used but with different configurations (different base address, timeout, and credentials for example).

The following example uses the same TodoService typed client which was shown under the typed clients section.

First register the named and typed clients.

using Shared;
using TypedHttp.Example;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHttpClient<TodoService>("primary"
    client =>
    {
        // Configure the primary typed client
        client.BaseAddress = new Uri("https://primary-host-address.com/");
        client.Timeout = TimeSpan.FromSeconds(3);
    });

// Register the same typed client but with different settings
builder.Services.AddHttpClient<TodoService>("secondary"
    client =>
    {
        // Configure the secondary typed client
        client.BaseAddress = new Uri("https://secondary-host-address.com/");
        client.Timeout = TimeSpan.FromSeconds(10);
    });

In the preceding code:

  • The first AddHttpClient call registers a TodoService typed client under the primary name. The underlying HttpClient points to the primary service and has a short timeout.
  • The second AddHttpClient call registers a TodoService typed client under the secondary name. The underlying HttpClient points to the secondary service and has a longer timeout.
using IHost host = builder.Build();

// Fetch an IHttpClientFactory instance to create a named client
IHttpClientFactory namedClientFactory =
    host.Services.GetRequiredService<IHttpClientFactory>();

// Fetch an ITypedHttpClientFactory<TodoService> instance to create a named and typed client
ITypedHttpClientFactory<TodoService> typedClientFactory  =
    host.Services.GetRequiredService<ITypedHttpClientFactory<TodoService>>();

// Create a TodoService instance against the primary host
var primaryClient = namedClientFactory.CreateClient("primary");
var todoService = typedClientFactory.CreateClient(primaryClient);

In the preceding code:

  • An IHttpClientFactory instance is retrieved from the DI container to be able to create named clients via its CreateClient method.
  • An ITypedHttpClientFactory<TodoService> instance is retrieved from the DI container to be able to create typed clients via its CreateClient method.
    • This CreateClient overload received a named HttpClient (with the proper configuration) as its parameter.
    • The created todoService is configured to use the primary service.

Note

The IHttpClientFactory type resides inside the System.Net.Http namespaces whereas the ITypedHttpClientFactory type inside the Microsoft.Extensions.Http.

Important

Use the implementation class (in the preceding example the TodoService) as the type parameter for the ITypedHttpClientFactory. Even if you have an abstraction (like ITodoService interface) as well, you still have to use the implementation. If you accidentally use the abstraction (ITodoService) then when you call its CreateClient it will throw an InvalidOperationException.

try
{
    Todo[] todos = await todoService.GetUserTodosAsync(4);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    // The request timed out against the primary host

    // Create a TodoService instance against the secondary host
    var fallbackClient = namedClientFactory.CreateClient("secondary");
    var todoFallbackService = typedClientFactory.CreateClient(fallbackClient);

    // Issue request against the secondary host
    Todo[] todos = await todoFallbackService.GetUserTodosAsync(4);
}

In the preceding code:

  • It tries to issue a request against the primary service.
  • If the request times out (takes longer than 3 seconds) then it throws a TaskCanceledException with a TimeoutException inner.
  • In case of timeout a new client is created and used which is now targeting the secondary service.

Generated clients

IHttpClientFactory can be used in combination with third-party libraries such as Refit. Refit is a REST library for .NET. It allows for declarative REST API definitions, mapping interface methods to endpoints. An implementation of the interface is generated dynamically by the RestService, using HttpClient to make the external HTTP calls.

Consider the following record type:

namespace Shared;

public record class Todo(
    int UserId,
    int Id,
    string Title,
    bool Completed);

The following example relies on the Refit.HttpClientFactory NuGet package, and is a simple interface:

using Refit;
using Shared;

namespace GeneratedHttp.Example;

public interface ITodoService
{
    [Get("/todos?userId={userId}")]
    Task<Todo[]> GetUserTodosAsync(int userId);
}

The preceding C# interface:

  • Defines a method named GetUserTodosAsync that returns a Task<Todo[]> instance.
  • Declares a Refit.GetAttribute attribute with the path and query string to the external API.

A typed client can be added, using Refit to generate the implementation:

using GeneratedHttp.Example;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Refit;
using Shared;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddRefitClient<ITodoService>()
    .ConfigureHttpClient(client =>
    {
        // Set the base address of the named client.
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");

        // Add a user-agent default request header.
        client.DefaultRequestHeaders.UserAgent.ParseAdd("dotnet-docs");
    });

The defined interface can be consumed where necessary, with the implementation provided by DI and Refit.

Make POST, PUT, and DELETE requests

In the preceding examples, all HTTP requests use the GET HTTP verb. HttpClient also supports other HTTP verbs, including:

  • POST
  • PUT
  • DELETE
  • PATCH

For a complete list of supported HTTP verbs, see HttpMethod. For more information on making HTTP requests, see Send a request using HttpClient.

The following example shows how to make an HTTP POST request:

public async Task CreateItemAsync(Item item)
{
    using StringContent json = new(
        JsonSerializer.Serialize(item, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
        Encoding.UTF8,
        MediaTypeNames.Application.Json);

    using HttpResponseMessage httpResponse =
        await httpClient.PostAsync("/api/items", json);

    httpResponse.EnsureSuccessStatusCode();
}

In the preceding code, the CreateItemAsync method:

  • Serializes the Item parameter to JSON using System.Text.Json. This uses an instance of JsonSerializerOptions to configure the serialization process.
  • Creates an instance of StringContent to package the serialized JSON for sending in the HTTP request's body.
  • Calls PostAsync to send the JSON content to the specified URL. This is a relative URL that gets added to the HttpClient.BaseAddress.
  • Calls EnsureSuccessStatusCode to throw an exception if the response status code does not indicate success.

HttpClient also supports other types of content. For example, MultipartContent and StreamContent. For a complete list of supported content, see HttpContent.

The following example shows an HTTP PUT request:

public async Task UpdateItemAsync(Item item)
{
    using StringContent json = new(
        JsonSerializer.Serialize(item, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
        Encoding.UTF8,
        MediaTypeNames.Application.Json);

    using HttpResponseMessage httpResponse =
        await httpClient.PutAsync($"/api/items/{item.Id}", json);

    httpResponse.EnsureSuccessStatusCode();
}

The preceding code is very similar to the POST example. The UpdateItemAsync method calls PutAsync instead of PostAsync.

The following example shows an HTTP DELETE request:

public async Task DeleteItemAsync(Guid id)
{
    using HttpResponseMessage httpResponse =
        await httpClient.DeleteAsync($"/api/items/{id}");

    httpResponse.EnsureSuccessStatusCode();
}

In the preceding code, the DeleteItemAsync method calls DeleteAsync. Because HTTP DELETE requests typically contain no body, the DeleteAsync method doesn't provide an overload that accepts an instance of HttpContent.

To learn more about using different HTTP verbs with HttpClient, see HttpClient.

HttpClient lifetime management

A new HttpClient instance is returned each time CreateClient is called on the IHttpClientFactory. One HttpClientHandler instance is created per client name. The factory manages the lifetimes of the HttpClientHandler instances.

IHttpClientFactory caches the HttpClientHandler instances created by the factory to reduce resource consumption. An HttpClientHandler instance may be reused from the cache when creating a new HttpClient instance if its lifetime hasn't expired.

Caching of handlers is desirable as each handler typically manages its own underlying HTTP connection pool. Creating more handlers than necessary can result in socket exhaustion and connection delays. Some handlers also keep connections open indefinitely, which can prevent the handler from reacting to DNS changes.

The default handler lifetime is two minutes. To override the default value, call SetHandlerLifetime for each client, on the IServiceCollection:

services.AddHttpClient("Named.Client")
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

Important

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

Keeping a single HttpClient instance alive for a long duration is a common pattern that can be used as an alternative to IHttpClientFactory, however, this pattern requires additional setup, such as PooledConnectionLifetime. You can use either long-lived clients with PooledConnectionLifetime, or short-lived clients created by IHttpClientFactory. For information about which strategy to use in your app, see Guidelines for using HTTP clients.

Configure the HttpMessageHandler

It may be necessary to control the configuration of the inner HttpMessageHandler used by a client.

An IHttpClientBuilder is returned when adding named or typed clients. The ConfigurePrimaryHttpMessageHandler extension method can be used to define a delegate on the IServiceCollection. The delegate is used to create and configure the primary HttpMessageHandler used by that client:

.ConfigurePrimaryHttpMessageHandler(() =>
{
    return new HttpClientHandler
    {
        AllowAutoRedirect = false,
        UseDefaultCredentials = true
    };
});

Configuring the HttClientHandler lets you specify a proxy for the HttpClient instance among various other properties of the handler. For more information, see Proxy per client.

Additional configuration

There are several additional configuration options for controlling the IHttpClientHandler:

Method Description
AddHttpMessageHandler Adds an additional message handler for a named HttpClient.
AddTypedClient Configures the binding between the TClient and the named HttpClient associated with the IHttpClientBuilder.
ConfigureHttpClient Adds a delegate that will be used to configure a named HttpClient.
ConfigureHttpMessageHandlerBuilder Adds a delegate that will be used to configure message handlers using HttpMessageHandlerBuilder for a named HttpClient.
ConfigurePrimaryHttpMessageHandler Configures the primary HttpMessageHandler from the dependency injection container for a named HttpClient.
RedactLoggedHeaders Sets the collection of HTTP header names for which values should be redacted before logging.
SetHandlerLifetime Sets the length of time that a HttpMessageHandler instance can be reused. Each named client can have its own configured handler lifetime value.

Using IHttpClientFactory together with SocketsHttpHandler

The SocketsHttpHandler implementation of HttpMessageHandler was added in .NET Core 2.1, which allows PooledConnectionLifetime to be configured. This setting is used to ensure that the handler reacts to DNS changes, so using SocketsHttpHandler is considered to be an alternative to using IHttpClientFactory. For more information, see Guidelines for using HTTP clients.

However, SocketsHttpHandler and IHttpClientFactory can be used together improve configurability. By using both of these APIs, you benefit from configurability on both a low level (for example, using LocalCertificateSelectionCallback for dynamic certificate selection) and a high level (for example, leveraging DI integration and several client configurations).

To use both APIs:

  1. Specify SocketsHttpHandler as PrimaryHandler and set up its PooledConnectionLifetime (for example, to a value that was previously in HandlerLifetime).
  2. As SocketsHttpHandler will handle connection pooling and recycling, then handler recycling at the IHttpClientFactory level is not needed anymore. You can disable it by setting HandlerLifetime to Timeout.InfiniteTimeSpan.
services.AddHttpClient(name)
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        return new SocketsHttpHandler()
        {
            PooledConnectionLifetime = TimeSpan.FromMinutes(2)
        };
    })
    .SetHandlerLifetime(Timeout.InfiniteTimeSpan); // Disable rotation, as it is handled by PooledConnectionLifetime

Avoid typed clients in singleton services

When using the named client approach, IHttpClientFactory is injected into services, and HttpClient instances are created by calling CreateClient every time an HttpClient is needed.

However, with the typed client approach, typed clients are transient objects usually injected into services. That may cause a problem because a typed client can be injected into a singleton service.

Important

Typed clients are expected to be short-lived in the same sense as HttpClient instances created by IHttpClientFactory (for more information, see HttpClient lifetime management). As soon as a typed client instance is created, IHttpClientFactory has no control over it. If a typed client instance is captured in a singleton, it may prevent it from reacting to DNS changes, defeating one of the purposes of IHttpClientFactory.

If you need to use HttpClient instances in a singleton service, consider the following options:

  • Use the named client approach instead, injecting IHttpClientFactory in the singleton service and recreating HttpClient instances when necessary.
  • If you require the typed client approach, use SocketsHttpHandler with configured PooledConnectionLifetime as a primary handler. For more information on using SocketsHttpHandler with IHttpClientFactory, see the section Using IHttpClientFactory together with SocketsHttpHandler.

Message Handler Scopes in IHttpClientFactory

IHttpClientFactory creates a separate DI scope per each HttpMessageHandler instance. These DI scopes are separate from application DI scopes (for example, ASP.NET incoming request scope, or a user-created manual DI scope), so they will not share scoped service instances. Message Handler scopes are tied to handler lifetime and can outlive application scopes, which can lead to, for example, reusing the same HttpMessageHandler instance with same injected scoped dependencies between several incoming requests.

Diagram showing two application DI scopes and a separate message handler scope

Users are strongly advised not to cache scope-related information (such as data from HttpContext) inside HttpMessageHandler instances and use scoped dependencies with caution to avoid leaking sensitive information.

If you require access to an app DI scope from your message handler, for authentication as an example, you'd encapsulate scope-aware logic in a separate transient DelegatingHandler, and wrap it around an HttpMessageHandler instance from the IHttpClientFactory cache. To access the handler call IHttpMessageHandlerFactory.CreateHandler for any registered named client. In that case, you'd create an HttpClient instance yourself using the constructed handler.

Diagram showing gaining access to app DI scopes via a separate transient message handler and IHttpMessageHandlerFactory

The following example shows creating an HttpClient with a scope-aware DelegatingHandler:

if (scopeAwareHandlerType != null)
{
    if (!typeof(DelegatingHandler).IsAssignableFrom(scopeAwareHandlerType))
    {
        throw new ArgumentException($"""
            Scope aware HttpHandler {scopeAwareHandlerType.Name} should
            be assignable to DelegatingHandler
            """);
    }

    // Create top-most delegating handler with scoped dependencies
    scopeAwareHandler = (DelegatingHandler)_scopeServiceProvider.GetRequiredService(scopeAwareHandlerType); // should be transient
    if (scopeAwareHandler.InnerHandler != null)
    {
        throw new ArgumentException($"""
            Inner handler of a delegating handler {scopeAwareHandlerType.Name} should be null.
            Scope aware HttpHandler should be registered as Transient.
            """);
    }
}

// Get or create HttpMessageHandler from HttpClientFactory
HttpMessageHandler handler = _httpMessageHandlerFactory.CreateHandler(name);

if (scopeAwareHandler != null)
{
    scopeAwareHandler.InnerHandler = handler;
    handler = scopeAwareHandler;
}

HttpClient client = new(handler);

A further workaround can follow with an extension method for registering a scope-aware DelegatingHandler and overriding default IHttpClientFactory registration by a transient service with access to the current app scope:

public static IHttpClientBuilder AddScopeAwareHttpHandler<THandler>(
    this IHttpClientBuilder builder) where THandler : DelegatingHandler
{
    builder.Services.TryAddTransient<THandler>();
    if (!builder.Services.Any(sd => sd.ImplementationType == typeof(ScopeAwareHttpClientFactory)))
    {
        // Override default IHttpClientFactory registration
        builder.Services.AddTransient<IHttpClientFactory, ScopeAwareHttpClientFactory>();
    }

    builder.Services.Configure<ScopeAwareHttpClientFactoryOptions>(
        builder.Name, options => options.HttpHandlerType = typeof(THandler));

    return builder;
}

For more information, see the full example.

See also