blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

DI scopes in IHttpClientFactory message handlers don't work like you think they do

In this post I discuss how dependency injection scopes work in the context of IHttpClientFactory. The title of this post reflects the fact that they don't work like I previously expected them to!

This post assumes you already have a general idea of IHttpClientFactory and what it's used for, so if it's new to you, take a look at Steve Gordon's introduction to IHttpClientFactory, or see the docs.

In this post I look at how dependency injection scopes work when you're using IHttpClientFactory, how they relate to the "typical" request-based DI scope used in ASP.NET Core, and the implications of that for custom message handler implementations.

We'll start with a very brief overview of IHttpClientFactory and DI scopes, and then look at how the two interact.

Why use IHttpClientFactory?

IHttpClientFactory allows you to create HttpClient instances for interacting with HTTP APIs, using best practices to avoid common issues related to socket exhaustion and not respecting DNS settings. It does this by managing the HttpMessageHandler chain separately from the HttpClient instances.

HttpClient and HttpClientHandler pipeline

You can read about how IHttpClientFactory achieves this in my previous post but in brief:

  • IHttpClientFactory creates an HttpMessageHandler pipeline for each "named" client
  • After 2 minutes, the IHttpClientFactory creates a new HttpMessageHandler pipeline and uses that for new HttpClient instances.
  • Once the HttpClient instances referencing an "expired" handler pipeline have all been collected by the garbage collector, the pipeline is disposed.
  • IHttpClientFactory also makes it easy to add additional handlers to the handler pipeline.

That's obviously a very brief summary, but if you're not already familiar with IHttpClientFactory, I suggest reading Steve Gordon's series first.

To continue setting the scene, we'll take a brief look at dependency injection scopes.

Dependency Injection scopes and the request scope

In ASP.NET Core, services can be registered with the dependency injection (DI) container with one of three lifetimes:

  • Singleton: A single instance of the service is used throughout the lifetime of the application. All requests for the service return the same instance.
  • Scoped: Within a defined "scope", all requests for the service return the same instance. Requests from different scopes will return different instances.
  • Transient: A new instance of the service is created every time it is requested. Every request for the service returns a different instance.

Singleton and transient are the simplest, as they take the lifetime of a component to an extreme. Scoped is slightly more complex, as the behaviour varies depending on whether you are in the context of the same scope.

The pseudo code below demonstrates this - it won't compile, but hopefully you get the idea.

IServiceProvider rootContainer;
using (var scope1 = rootContainer.CreateScope())
{
      var service1 = scope1.ServiceProvider.GetService<ScopedService>();
      var service2 = scope1.ServiceProvider.GetService<ScopedService>();
      service1.ReferenceEquals(service2); // true
}

using (var scope2 = rootContainer.CreateScope())
{
      var service3 = scope2.ServiceProvider.GetService<ScopedService>();
      service3.ReferenceEquals(service2); // false
}

The main question is when are those scopes created?

In ASP.NET Core, a new scope is created for each request. So each request uses a different instance of a scoped service.

A common example of this is EF Core's DbContext - the same instance of this class is used throughout a request, but a different instance is used between requests.

This is by far the most common way to interact with scopes in ASP.NET Core. But there are special cases where you need to "manually" create scopes, when you are executing outside of the context of a request. For example:

It's generally pretty apparent when you're running into an issue like this, as you're trying to access scoped services from a singleton context.

Where things really get interesting is when you're consuming services from scopes with overlapping lifetimes. That sounds confusing, but it's something you'll need to get your head around if you create custom HttpMessageHandlers for IHttpClientFactory!

HttpMessageHandler lifetime in IHttpClientFactory

As we've already discussed, IHttpClientFactory manages the lifetime of your HttpMessageHandler pipeline separately from the HttpClient instances. HttpClient instances are created new every time, but for the 2 minutes before a handler expires, every HttpClient with a given name uses the same handler pipeline.

I've really emphasised that, as it's something I didn't understand from the documentation and previous posts on IHttpClientFactory. The documentation constantly talks about a "pool" of handlers, but that feels a bit misleading to me - there's only a single handler in the "pool" used to create new instances of HttpClient. That's not what I think of as a pool!

The documentation isn't incorrect per-se, but it does seem a bit misleading. This image seems to misrepresent the situation for example. That's part of the reason for my digging into the code behind IHttpClientFactory and writing it up in my previous post.

My assumption was that a "pool" of available handlers were maintained, and that IHttpClientFactory would hand out an unused handler from this pool to new instances of HttpClient.

That is not the case.

A single handler pipeline will be reused across multiple calls to CreateClient(). After 2 minutes, this handler is "expired", and so is no longer handed out to new HttpClients. At that point, you get a new active handler, that will be used for all subsequent CreateClient() calls. The expired handler is moved to a queue for clean up once it is no longer in use.

An image showing my assumption and reality

The fact that the handler pipeline is shared between multiple HttpClient instances isn't a problem in terms of thread safety—after all, the advice prior to IHttpClientFactory was to use a single HttpClient for your application. Where things get interesting is the impact this has on DI scopes, especially if you're writing your own custom HttpMessageHandlers.

Scope duration in IHttpClientFactory

This brings us to the crux of this post—the duration of a DI scope with IHttpClientFactory.

As I showed in my previous post, IHttpClientFactory, creates a new DI scope when it creates a new handler pipeline. It uses this scope to create each of the handlers in the pipeline, and stores the scope in an ActiveHandlerTrackingEntry instance, along with the handler pipeline itself.

When the handler expires (after 2 minutes), and once all the HttpClient references to the handler pipeline have been garbage collected, the handler pipeline, and the DI scope used to create the handler, are disposed.

Remember, for 2 minutes, the same handler pipeline will be used for all calls to CreateClient() for a given named handler. That applies across all requests, even though each request uses it's own DI scope for the purpose of retrieving services. The DI scope for the handler pipeline is completely separate to the DI scope for the request.

Image of a handler scope living longer than two requests

This was something I hadn't given much though to, given my previous misconceptions of the "pool" of handler pipelines. The next question is: does this cause us any problems? The answer (of course) is "it depends".

Before we get to that, I'll provide a concrete example demonstrating the behaviour above.

An example of unexpected (for me) scoped service behaviour in IHttpClientFactory

Lets imagine you have some "scoped" service, that returns an ID. Each instance of the service should always return the same ID, but different instances should return different IDs. For example:

public class ScopedService
{
    public Guid InstanceId { get; } = Guid.NewGuid();
}

You also have a custom HttpMessageHandler. Steve discusses custom handlers in his series, so I'll just present a very basic handler below which uses the ScopedService defined above, and logs the InstanceId:

public class ScopedMessageHander: DelegatingHandler
{
    private readonly ILogger<ScopedMessageHander> _logger;
    private readonly ScopedService _service;

    public ScopedMessageHander(ILogger<ScopedMessageHander> logger, ScopedService service))
    {
        _logger = logger;
        _service = service;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        // Constant across instances
        var instanceId = scopedService.InstanceId;
        _logger.LogInformation("Service ID in handler: {InstanceId}", );

        return base.SendAsync(request, cancellationToken);
    }
}

Next, we'll add a named HttpClient client in ConfigureServices(), and add our custom handler to its handler pipeline. You also have to register the ScopedMessageHandler as a service in the container explicitly, along with the ScopedService implementation:

public void ConfigureServices(IServiceCollection services)
{
    // Register the scoped services and add API controllers
    services.AddControllers();
    services.AddScoped<ScopedService>();

    // Add a typed client that fetches some dummy JSON
    services.AddHttpClient("test", client =>
    {
        client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
    })
    // Add our custom handler to the "test" handler pipeline
    .AddHttpMessageHandler<ScopedMessageHander>();

    // Register the message handler with the pipeline
    services.AddTransient<ScopedMessageHander>();
}

Finally, we have an API controller to test the behaviour. The controller below does two things:

  • Uses an injected ScopedService, and logs the instance's ID
  • Uses IHttpClientFactory to retrieve the named client "test", and sends a GET request. This executes the custom handler in the pipeline, logging its injected ScopedService instance ID.
[ApiController]
public class ValuesController : ControllerBase
{
    private readonly IHttpClientFactory _factory;
    private readonly ScopedService _service;
    private readonly ILogger<ValuesController> _logger;
    public ValuesController(IHttpClientFactory factory, ScopedService service, ILogger<ValuesController> logger)
    {
        _factory = factory;
        _service = service;
        _logger = logger;
    }

    [HttpGet("values")]
    public async Task<string> GetAsync()
    {
        // Get the scoped service's ID
        var instanceId = _service.InstanceId
        _logger.LogInformation("Service ID in controller {InstanceId}", instanceId);

        // Retrieve an instance of the test client, and send a request
        var client = _factory.CreateClient("test");
        var result = await client.GetAsync("posts");

        // Just return a response, we're not interested in this bit for now
        result.EnsureSuccessStatusCode();
        return await result.Content.ReadAsStringAsync();
    }
}

All this setup is designed to demonstrate the relationship between different ScopedServices. Lets take a look at the logs when we make two requests in quick succession

info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.

# Request 1
info: ScopedHandlers.Controllers.ValuesController[0]
      Service ID in controller d553365d-2799-4618-ad3a-2a4b7dcbf15e
info: ScopedHandlers.ScopedMessageHander[0]
      Service ID in handler: 5c6b1b75-7f86-4c4f-9c90-23c6df65d6c6

# Request 2
info: ScopedHandlers.Controllers.ValuesController[0]
      Service ID in controller af64338f-8e50-4a1f-b751-9f0be0bbad39
info: ScopedHandlers.ScopedMessageHander[0]
      Service ID in handler: 5c6b1b75-7f86-4c4f-9c90-23c6df65d6c6

As expected for a scoped service, the "Service ID in controller" log message changes with each request. The DI scope lasts for the length of the request: each request uses a different scope, so a new ScopedService is injected each request.

However, the ScopedService in the ScopedMessageHander is the same across both requests, and it's different to the ScopedService injected into the ValuesController. That's what we expect based on the discussion in the previous section, but it's not what I expected when I first started looking into this!

After two minutes, if we send another request, you'll see the "Service ID in handler" has changed. The handler pipeline from previous requests expired, and a new handler pipeline was created:

# Request 3
info: ScopedHandlers.Controllers.ValuesController[0]
      Service ID in controller eaa8a393-e573-48c9-8b26-9b09b180a44b
info: ScopedHandlers.ScopedMessageHander[0]
      Service ID in handler: 09ccb005-6434-4884-bc2d-6db7e0868d93

So, the question is: does it matter?

Does having mis-matched scopes matter?

The simple answer is: probably not.

If any of the following are true, then there's nothing to worry about:

  • No custom handlers. If you're not using custom HttpMessageHandlers, then there's nothing to worry about.
  • Stateless. If your custom handlers are stateless, as the vast majority of handlers will be, then the lifetime of the handler doesn't matter.
  • Static dependencies. Similarly, if the handler only depends on static (singleton) dependencies, then the lifetime of the handler doesn't matter here
  • Doesn't need to share state with request dependencies. Even if your handler requires non-singleton dependencies, as long as it doesn't need to share state with dependencies used in a request, you'll be fine.

The only situation I think you could run into issues is:

  • Requires sharing dependencies with request. If your handler requires using the same dependencies as the request in which it's invoked, then you could have problems.

The main example I can think of is EF Core.

A common pattern for EF Core is a "unit of work", that creates a new EF Core DbContext per request, does some work, and then persists those changes at the end of the request. If your custom handler needs to coordinate with the unit of work, then you could have problems unless you do extra work.

For example, imagine you have a custom handler that writes messages to an EF Core table. If you inject a DbContext into the custom handler, it will be a different instance of the DbContext than the one in your request. Additionally, this DbContext will last for the lifetime of the handler (2 minutes), not the short lifetime of a request.

So if you're in that situation, what should you do?

Accessing the Request scope from a custom HttpMessageHandler

Luckily, there is a solution. To demonstrate, I'll customise the ScopedMessageHander shown previously, so that the ScopedService it uses comes from the request's DI scope, instead of the DI scope used to create the custom handler. The key, is using IHttpContextAccessor.

Note that you have to add services.AddHttpContextAccessor() in your Startup.ConfigureServices() method to make IHttpContextAccessor available;

public class ScopedMessageHander: DelegatingHandler
{
    private readonly ILogger<ScopedMessageHander> _logger;
    private readonly IHttpContextAccessor _accessor;

    public ScopedMessageHander(ILogger<ScopedMessageHander> logger, IHttpContextAccessor accessor)
    {
        _logger = logger;
        _accessor = accessor;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        // The HttpContext will be null if used outside a request context, so check in practice!
        var httpConext = _accessor.HttpContext;

        // retrieve the service from the Request DI Scope
        var service = _accessor.HttpContext.RequestServices.GetRequiredService<ScopedService>();
        
        // The same scoped instance used in the controller
        var instanceId = service.InstanceId;
        _logger.LogInformation("Service ID in handler: {InstanceId}", );

        return base.SendAsync(request, cancellationToken);
    }
}

This approach uses the IHttpContextAccessor to retrieve the IServiceProvider that is scoped to the request. This allows you to retrieve the same instance that was injected into the ValuesController. Consequently, for every request, the logged values are the same in both the controller and the handler:

info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.

# Request 1
info: ScopedHandlers.Controllers.ValuesController[0]
      Service ID in controller eaa8a393-e573-48c9-8b26-9b09b180a44b
info: ScopedHandlers.ScopedMessageHander[0]
      Service ID in handler: eaa8a393-e573-48c9-8b26-9b09b180a44b

# Request 2
info: ScopedHandlers.Controllers.ValuesController[0]
      Service ID in controller c5c3087b-938d-4e11-ae49-22072a56cef6
info: ScopedHandlers.ScopedMessageHander[0]
      Service ID in handler: c5c3087b-938d-4e11-ae49-22072a56cef6

Even though the lifetime of the handler doesn't match the lifetime of the request, you can still execute the handler using services sourced from the same DI scope. This should allow you to work around any scoping issues you run into.

Summary

In this post I described how DI scopes with IHttpClientFactory. I showed that handlers are sourced from their own scope, which is separate from the request DI scope, which is typically where you consider scopes to be sourced from.

In most cases, this won't be a problem, but if an HttpMessageHandler requires using services from the "main" request then you can't use naïve constructor injection. Instead, you need to use IHttpContextAccessor to access the current request's HttpContext and IServiceProvider.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?