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.
You can read about how IHttpClientFactory
achieves this in my previous post but in brief:
IHttpClintFactory
creates anHttpMessageHandler
pipeline for each "named" client- After 2 minutes, the
IHttpClientFactory
creates a newHttpMessageHandler
pipeline and uses that for newHttpClient
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:
- Accessing scoped services from singleton services, such as
IConfigureOptions
. - Creating scopes for long-running processes, like
IHostedService
.
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 HttpClient
s. 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.
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 HttpMessageHandler
s.
Scope duration in IHttpClientFactory
This brings us to the crux of this post—the duration of a DI scope with IHttpClintFactory
.
As I showed in my previous post, IHttpClintFactory
, 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.
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 injectedScopedService
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 ScopedService
s. 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
HttpMessageHandler
s, 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 yourStartup.ConfigureServices()
method to makeIHttpContextAccessor
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
.