Skip to content

Add API to provide existing DI scope to HttpClientFactory #47091

Open
@CarnaViire

Description

@CarnaViire

Updated

The scope can be provided via the keyed services infra. The API to opt-in into Keyed services registration is proposed in #89755.

AddAsKeyedScoped() API will automatically opt in into the scope-propagating behavior, but ONLY in case keyed services infra ([FromKeyedServices...] or GetRequiredKeyedService) is used to inject/resolve the client.

Opt-in API design considerations -- separate from keyed services
namespace Microsoft.Extensions.DependencyInjection;

public static partial class HttpClientBuilderExtensions
{
    public static IHttpClientBuilder SetPropagateContextScope(this IHttpClientBuilder builder, bool val) {}
}

Usage:

services.AddHttpClient("foo")
    //.AddAsKeyedScoped()
    .SetPropagateContextScope(true);

services.AddHttpClient("foo")
    //.AddAsKeyedTransient()
    .SetPropagateContextScope(true); // if there are any scoped dependencies, won't resolve in singletons

services.AddHttpClient("foo")
    .SetPropagateContextScope(false); // explicitly opt-out

Alternalive namings:

  • PropagateScope
  • PropagateExistingScope
  • SetPreserveExistingScope(true/false)
  • SetSuppressHandlerScope(true/false) (there is existing "hidden" option with that name, but the usage is a bit different, so technically it can clash with existing usages + and not self-evident name)

Original proposal

Background and Motivation

HttpClientFactory allows users to register one or several HttpClient configurations in DI container and then instantiate HttpClients according to the respective configuration. A configuration can specify that HttpClient should use a specific HttpMessageHandler or even a chain of such handlers. When creating a client, HttpClientFactory caches and reuses HttpMessageHandlers to avoid creating too many connections and exhausting sockets, so handlers will live for a configurable timespan HandlerLifetime.

The problem begins when message handlers forming a chain have dependencies on other services from DI. In case the user wants to inject a scoped service into the message handler, they expect the scoped service instance to be from their existing unit-of-work scope. However, the current behavior is different -- in the existing implementation, the service instance will be from a new scope bound to message handler lifetime, i.e. it will be a different instance from what the user would expect.

This scope mismatch is not only confusing to customers, but also produces unsolvable bugs in user code, e.g. when the scoped service is supposed to be stateful within the scope, but this state is impossible to access from the message handler.

There is a number of GH issues and StackOverflow questions from users suffering from scope mismatch:

The solution leverages the following idea:

If we want to cache/reuse the connection, it is enough to cache/reuse the bottom-most handler of the chain (aka PrimaryHandler). Other handlers in the chain may be re-instantiated for each unit-of-work scope, so they will have the correct instances of the scoped services injected into them (which is desired by customers).

I believe new behavior should be opt-in, as there will be more allocations than before.

However, in order to leverage existing scope, HttpClientFactory should know about it. In the current implementation, the factory is registered in DI a singleton, so it doesn't have access to scopes.

The easiest way to allow HttpClientFactory to capture existing scope is to change its lifetime from singleton to transient. Transient services can (as well as singletons) be injected into services of all lifetimes, so all existing code will continue to work.

To maintain existing caching behavior, cache part of the factory will be moved out to a separate singleton service, but this is an implementation detail that does not affect API.

Proposed API

namespace Microsoft.Extensions.DependencyInjection
{
    public static partial class HttpClientBuilderExtensions
    {
        ...
        public static IHttpClientBuilder SetHandlerLifetime(this IHttpClientBuilder builder, TimeSpan handlerLifetime) { ... }
+       public static IHttpClientBuilder SetPreserveExistingScope(this IHttpClientBuilder builder, bool preserveExistingScope) { ... }
    }
}

namespace Microsoft.Extensions.Http
{
    public partial class HttpClientFactoryOptions
    {
        ...
        public TimeSpan HandlerLifetime { get; set; }
+       public bool PreserveExistingScope { get; set; } // default is false = old behavior
    }
}

namespace System.Net.Http
{
-   // registered in DI as singleton
+   // registered in DI as transient
    public partial interface IHttpClientFactory
    {
        HttpClient CreateClient(string name);
    }
}

Usage Examples

The only change needed for both named and typed clients is to opt-in via callling SetPreserveExistingScope(true)

Named client example:

// registration
class Program
{
    private static void ConfigureServices(HostBuilderContext context, IServiceCollection services)
    {
        services.AddScoped<IWorker, NamedClientWorker>();
        services.AddScoped<HandlerWithScopedDependency>();
        services.AddHttpClient("github")
            .AddHttpMessageHandler<HandlerWithScopedDependency>()
+           .SetPreserveExistingScope(true);
        ...
    }
    ...
}

// usage
class NamedClientWorker : IWorker
{
   private IHttpClientFactory _clientFactory;

+   // HttpClientFactory will capture an existing scope where it was resolved
    public NamedClientWorker(IHttpClientFactory clientFactory)
    {
        _clientFactory = clientFactory;
    }

    public async Task DoWorkAsync()
    {
+       // HandlerWithScopedDependency inside HttpClient will be resolved
+       // within an existing scope 
        HttpClient client = _clientFactory.CreateClient("github");
        var response = await client.GetStringAsync(GetRepositoriesUrl(username));
        ...
    }
}

Typed client example:

// registration
class Program
{
    private static void ConfigureServices(HostBuilderContext context, IServiceCollection services)
    {
        services.AddScoped<IWorker, TypedClientWorker>();
        services.AddScoped<HandlerWithScopedDependency>();
        services.AddHttpClient<IGithubClient, GithubClient>
            .AddHttpMessageHandler<HandlerWithScopedDependency>()
+           .SetPreserveExistingScope(true);
        ...
    }
    ...
}

// usage
class TypedClientWorker : IWorker
{
    private IGithubClient _githubClient;

    public TypedClientWorker(IGithubClient githubClient)
    {
        _githubClient = githubClient;
    }

    public async Task DoWorkAsync()
    {
        var response = await _githubClient.GetRepositories(username);
        ...
    }
}

// typed client impl
class GithubClient : IGithubClient
{
    private HttpClient _client;

    // HttpClient is created by IHttpClientFactory  
+   // HttpClientFactory will capture an existing scope
+   // HandlerWithScopedDependency inside HttpClient will be resolved
+   // within an existing scope 
    public GithubClient(HttpClient client)
    {
        _client = client;
    }

    public async Task<string> GetRepositories(string username)
    {
        return await _client.GetStringAsync(GetRepositoriesUrl(username));
    }
}

Alternative Designs

If we don't want to change the current lifetime of HttpClientFactory (i.e. let it stay singleton), we should provide the scope to it in some other way.

In order to do that, we may have an additional scoped service, which will have access to the current unit-of-work scope and to the singleton HttpClientFactory.

Let me note that because of how DI works, we couldn't use existing interface IHttpClientFactory for a new scoped service, because a singleton service is already registered on it. That's why a new interface IScopedHttpClientFactory is added here.

namespace Microsoft.Extensions.DependencyInjection
{
    public static partial class HttpClientBuilderExtensions
    {
        ...
        public static IHttpClientBuilder SetHandlerLifetime(this IHttpClientBuilder builder, TimeSpan handlerLifetime) { ... }
+       public static IHttpClientBuilder SetPreserveExistingScope(this IHttpClientBuilder builder, bool preserveExistingScope) { ... }
    }
}

namespace Microsoft.Extensions.Http
{
    public partial class HttpClientFactoryOptions
    {
        ...
        public TimeSpan HandlerLifetime { get; set; }
+       public bool PreserveExistingScope { get; set; } // default is false = old behavior
    }
}

namespace System.Net.Http
{
    // registered in DI as singleton
    public partial interface IHttpClientFactory
    {
        HttpClient CreateClient(string name);
    }

+   // registered in DI as scoped
+   public partial interface IScopedHttpClientFactory
+   {
+       HttpClient CreateClient(string name);
+   }
}

Alternative design's usage examples:

For named clients, user will also need to change the injected factory after opt-in. For typed clients, just opting-in is enough, the magic will happen on its own.

Named client example:

// registration
class Program
{
    private static void ConfigureServices(HostBuilderContext context, IServiceCollection services)
    {
        services.AddScoped<IWorker, NamedClientWorker>();
        services.AddScoped<HandlerWithScopedDependency>();
        services.AddHttpClient("github")
            .AddHttpMessageHandler<HandlerWithScopedDependency>()
+           .SetPreserveExistingScope(true);
        ...
    }
    ...
}

// usage
class NamedClientWorker : IWorker
{
-   private IHttpClientFactory _clientFactory;
+   private IScopedHttpClientFactory _clientFactory;

    public NamedClientWorker(
-       IHttpClientFactory clientFactory)
+       IScopedHttpClientFactory clientFactory)
    {
        _clientFactory = clientFactory;
    }

    public async Task DoWorkAsync()
    {
        HttpClient client = _clientFactory.CreateClient("github");
        var response = await client.GetStringAsync(GetRepositoriesUrl(username));
        ...
    }
}

Typed client example:

// registration
class Program
{
    private static void ConfigureServices(HostBuilderContext context, IServiceCollection services)
    {
        services.AddScoped<IWorker, TypedClientWorker>();
        services.AddScoped<HandlerWithScopedDependency>();
        services.AddHttpClient<IGithubClient, GithubClient>
            .AddHttpMessageHandler<HandlerWithScopedDependency>()
+           .SetPreserveExistingScope(true);
        ...
    }
    ...
}

// usage
class TypedClientWorker : IWorker
{
    private IGithubClient _githubClient;

    public TypedClientWorker(IGithubClient githubClient)
    {
        _githubClient = githubClient;
    }

    public async Task DoWorkAsync()
    {
        var response = await _githubClient.GetRepositories(username);
        ...
    }
}

// typed client impl
class GithubClient : IGithubClient
{
    private HttpClient _client;

-   // HttpClient is created by IHttpClientFactory 
+   // HttpClient is automatically created by IScopedHttpClientFactory after opt-in
    public GithubClient(HttpClient client)
    {
        _client = client;
    }

    public async Task<string> GetRepositories(string username)
    {
        return await _client.GetStringAsync(GetRepositoriesUrl(username));
    }
}

Risks

For existing usages - the risk is low. Transient HttpClientFactory can be injected in all service lifetimes as well as a singleton, so all existing code will continue to work as before and will maintain old behavior for creating HttpMessageHandlers. The only thing that will change is that there will be more allocations (every injection of HttpClientFactory will create a new instance).

New opt-in behavior is only meaningful within a scope, so HttpClientFactory should be resolved within a scope for PreserveExistingScope=true to work. However, no need to add any additional checks, this will be checked by DI's Scope Validation.

Substituting or modifying PrimaryHandler in case of PreserveExistingScope=true will be currently forbidden (InvalidOperationException during DI configuration). This is due to inability to assess security risks and avoid unnesessary object creations. HttpMessageHandlerBuilder.Build() will be called for each scope and not once in a (primary) handler lifetime as before. If it contains substituting or modifying PrimaryHandler, it will not work as expected, but will produce potential security risk and impact performance by creating redundant PrimaryHandlers to be thrown away. Addressing the risks of allowing PrimaryHandler modification will require additional API change.


2021-02-11 Edit: Changed main proposal to be about transient HttpClientFactory. Moved IScopedHttpClientFactory to alternatives. Removed option with IServiceProvider completely as it is both a bad practice and inconvenient to use.

2021-01-21 Edit: I've removed IScopedHttpMessageHandlerFactory from the proposal. It was initially added to correlate with IHttpMessageHandlerFactory, but actual usage examples where only scoped message handler would be needed but not HttpClient are not clear. It can be easily added later if there will be any demand for that.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions