Skip to content

HttpClientFactory support for keyed DI #89755

Closed
@JamesNK

Description

@JamesNK

Updated by @CarnaViire

UPD: Added Alternative Designs section, added opt-out API to the proposal

UPD2: Punted Scope Mismatch fix


HttpClientFactory allows for named clients. The new keyed DI feature can be used to resolve the clients by their name.

API

namespace Microsoft.Extensions.DependencyInjection;

public static partial class HttpClientBuilderExtensions // existing
{
    public static IHttpClientBuilder AddAsKeyedScoped(this IHttpClientBuilder builder) {} // new
    // alternatives:
    //   AsKeyed(this IHttpClientBuilder builder, ServiceLifetime lifetime)
    //   SetKeyedLifetime(this IHttpClientBuilder builder, ServiceLifetime lifetime)

    // UPD: optional -- opt-out API
    public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder) {} // new
    // alternatives:
    //   DropKeyed(this IHttpClientBuilder builder)
    //   DisableKeyedLifetime(this IHttpClientBuilder builder)
}

Usage

services.AddHttpClient("foo", c => c.BaseAddress = new Uri("https://foo.example.com"))
    .UseSocketsHttpHandler(h => h.UseCookies = false)
    .AddHttpMessageHandler<MyAuthHandler>()
    .AddAsKeyedScoped();

services.AddHttpClient("bar")
    .AddAsKeyedScoped()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://bar.example.com"));

services.AddHttpClient<BazClient>(c => c.BaseAddress = new Uri("https://baz.example.com"))
    .RemoveAsKeyed(); // explicitly opt-out

// ...

public class MyController
{
    public MyController(
        [FromKeyedServices("foo")] HttpClient fooClient,
        [FromKeyedServices("bar")] HttpClient barClient)
    {
        _fooClient = fooClient;
        _barClient = barClient;
    }
}

Also, should be able to do the following

services.ConfigureHttpClientDefaults(
    b => b.AddAsKeyedScoped()); // "AnyKey"-like registration -- all clients opted in

Considerations

1. Why opt-in and not on by default

Opting in rather than out, because it changes the lifetime from "essentially Transient" to Scoped.
([UPD2: punted] Plus we expect to fix the scope mismatch problem described by #47091, in case Keyed Services infra ([FromKeyedServices...]) is used to inject a client -- but this fix must be opt-in in one way or the other, at least in this release)

Also I believe there's no straightforward API to un-register a service from service collection once added -- I believe it's done by manually removing the added descriptor from the service collection.

(We can consider making keyed registration a default in the next release, but we'd need to think about good way to opt-out then)

UPD: Added opt-out API to the proposal

2. Why only Keyed Scoped (and not Keyed Transient)

HttpClient is IDisposable, and we want to avoid multiple instances being captured by the service provider. Asking for a "new" client each time is a part of HttpClientFactory guidelines, but DI will capture all IDisposables and hold them until the end of the application (for a root provider; or the end of the scope for a scope provider). Captured Transients will break and/or delay rotation clean up, resulting in a memory leak. There's no way to avoid the capturing that I'm aware of.

3. How it should be used in Singletons

By using the "old way" = using IHttpClientFactory.CreateClient() -- this will continue working as before = creating a scope per handler lifetime.

4. What about Typed Clients

If opting into KeyedScoped, Typed clients will [UPD2: can] be re-registered as scoped services, rather than transients. (This was actually a long-existing ask that we couldn't implement without some kind of opt-in #36067)

This will mean that the Typed clients will stop working in Singletons -- but them "working" there is actually a pitfall, since they're captured for the whole app lifetime and thus not able to participate in handler rotation.

Given that a Typed client is tied with a single named client, it doesn't make much sense to register it as keyed. I see a Typed client as functionally analogous to a service with a [FromKeyedServices...] dependance on a named client with name = service type name. See the example below.

services.AddHttpClient<BazClient>(c => c.BaseAddress = new Uri("https://baz.example.com"))
    .AddAsKeyedScoped();

public class BazClient
{
    public BazClient(
        HttpClient httpClient,
        ISomeOtherService someOtherDependency)
    {
        _httpClient = httpClient;
        _someOtherDependency = someOtherDependency;
    }
}

// -- EQUAL TO --

services.AddScoped<BazClient>();
services.AddHttpClient(nameof(BazClient), c => c.BaseAddress = new Uri("https://baz.example.com"))
    .AddAsKeyedScoped();

public class BazClient
{
    public BazClient(
        [FromKeyedServices(nameof(BazClient))] HttpClient httpClient,
        ISomeOtherService someOtherDependency)
    {
        _httpClient = httpClient;
        _someOtherDependency = someOtherDependency;
    }
}

FWIW I'd even suggest moving away from the Typed Client approach and substitute it with the keyed approach instead. It will also give more freedom if e.g. multiple instances of a "typed client" with different named clients are needed:

services.AddKeyedScoped<IClient, MyClient>(KeyedService.AnyKey);

services.AddHttpClient("foo", c => c.BaseAddress = new Uri("https://foo.example.com")).AddAsKeyedScoped();
services.AddHttpClient("bar", c => c.BaseAddress = new Uri("https://bar.example.com")).AddAsKeyedScoped();

public class MyClient : IClient
{
    public MyClient([ServiceKey] string name, IServiceProvider sp)
    {
        _httpClient = sp.GetRequiredKeyedService<HttpClient>(name);
    }
}

// ...

provider.GetRequiredKeyedService<IClient>("foo"); // depends on "foo" client
provider.GetRequiredKeyedService<IClient>("bar"); // depends on "bar" client

provider.GetRequiredKeyedService<IClient>("bad-name"); // throws, as no keyed HttpClient with such name exists

Alternative Designs

These are based around passing the lifetime as a parameter to avoid multiple methods. This is different from ordinary DI registrations, but then again, HttpClientFactory is already a different API set.

1. AsKeyed

AsKeyed(ServiceLifetime) + DropKeyed()

  • ➕ minimalistic
  • ➕ allows for other lifetimes
  • ❔ no "paired" opt-out verb (like Add-Remove) to use
    • used DropKeyed instead. Inspiration:
      • SocketOptionName.DropMembership (paired with AddMembership)
      • UdpClient.DropMulticastGroup (paired with JoinMulticastGroup)
  • other similar AsKeyed alternatives:
    • Keyed(ServiceLifetime) -- ➕ even more minimalistic
    • AddAsKeyed(ServiceLifetime) -- ❔ pairs with the alternative RemoveAsKeyed
  • other similar DropKeyed alternatives:
    • RemoveAsKeyed -- ❔ from main proposal, with "As" inside
// namespace Microsoft.Extensions.DependencyInjection
// classHttpClientBuilderExtensions

public static IHttpClientBuilder AsKeyed(this IHttpClientBuilder builder, ServiceLifetime lifetime) {}

// --- usage ---

services.AddHttpClient("foo")
    .AddHttpMessageHandler<MyAuthHandler>()
    .AsKeyed(ServiceLifetime.Scoped);

services.AddHttpClient("bar")
    .AsKeyed(ServiceLifetime.Scoped);

services.AddHttpClient("baz")
    .DropKeyed();

2. SetKeyedLifetime

SetKeyedLifetime(ServiceLifetime) + DisableKeyedLifetime()

  • ➕ allows for other lifetimes
  • ➕ makes sense even if/when the keyed registration is done by default (when used to change an already set lifetime)
  • ❔ aligns with with SetHandlerLifetime(TimeSpan) -- though also might be a bit confusing
  • ❔ "Disable" is not an actual pair for "Set" either, but SetKeyedLifetime(null) or ClearKeyedLifetime() are not clear enough
  • other similar alternatives:
    • EnableKeyedLifetime(ServiceLifetime) -- ➕ aligns with DisableKeyedLifetime
    • AddKeyedLifetime(ServiceLifetime) -- ❔ hints that a service descriptor will be added to the service collection (e.g. if called multiple times, it will be added multiple times)
    • SetKeyedServiceLifetime(ServiceLifetime) -- ❔ this one doesn't clash with SetHandlerLifetime that much, but is more bulky
// namespace Microsoft.Extensions.DependencyInjection
// classHttpClientBuilderExtensions

public static IHttpClientBuilder SetKeyedLifetime(this IHttpClientBuilder builder, ServiceLifetime lifetime) {}
public static IHttpClientBuilder DisableKeyedLifetime(this IHttpClientBuilder builder) {}


// --- usage ---

services.AddHttpClient("foo")
    .AddHttpMessageHandler<MyAuthHandler>()
    .SetKeyedLifetime(ServiceLifetime.Scoped);

services.AddHttpClient("bar")
    .SetKeyedLifetime(ServiceLifetime.Scoped);

services.AddHttpClient("baz")
    .DisableKeyedLifetime();

Some dismissed alternative namings/designs

  • AddHttpClient(...., ServiceLifetime) overload with a new parameter -- there are already 20 (!!) overloads of AddHttpClient, and we'd like to be able to opt-in in all configuration approaches (including ConfigureHttpClientDefaults)
  • AddKeyedScoped() (without "As") -- conflicts with existing APIs like AddHttpMessageHandler which adds to the client, not to the service collection
  • Anything other than Add, e.g. Register... -- doesn't align with ServiceCollection APIs, only Add../TryAdd.. is used there
    • UPD: AsKeyed and SetKeyedLifetime made way into Alternative Design section
  • AddKeyedClient(), AddKeyedScopedHttpClient(), AddScopedClient(), AddHttpClientAsKeyedScoped(),... -- not clear that not only HttpClient will be registered, but also the related HttpMessageHandler chain and, if present, a related Typed client.
  • AddKeyedServices(), AddToKeyedServices() -- not clear that it will be scoped

Old proposal by @CarnaViire

namespace Microsoft.Extensions.DependencyInjection;

public static partial class HttpClientBuilderExtensions
{
    public static IHttpClientBuilder AddAsKeyedTransient(this IHttpClientBuilder builder) {}
    public static IHttpClientBuilder AddAsKeyedScoped(this IHttpClientBuilder builder) {}
}

Usage:

services.AddHttpClient("foo", c => c.BaseAddress = new Uri("https://foo.example.com"))
    .UseSocketsHttpHandler(h => h.UseCookies = false)
    .AddHttpMessageHandler<MyAuthHandler>()
    .AddAsKeyedTransient();

services.AddHttpClient("bar")
    .AddAsKeyedScoped()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://bar.example.com"));

services.AddHttpClient<BazClient>(c => c.BaseAddress = new Uri("https://baz.example.com"))
    .AddAsKeyedScoped();

Alternatives:

services.AddHttpClient("foo")
    .AddKeyedServices(); // forces transient-only

// -OR-

services.AddHttpClient("foo")
    .AddClientAsKeyedTransient();
    .AddMessageHandlerAsKeyedScoped();

// -OR-

enum ClientServiceType
{
    NamedClient,
    TypedClient,
    MessageHandler
}

services.AddHttpClient("foo")
    .AddKeyedService(ClientServiceType.NamedClient, ServiceLifetime.Transient)
    .AddKeyedService(ClientServiceType.MessageHandler, ServiceLifetime.Scoped);

Original issue by @JamesNK

HttpClientFactory allows for named clients. The new keyed DI feature should be used to resolve clients by their name.

Required today:

services.AddHttpClient("adventureworks", c => c.BaseAddress = new Uri("https://www.adventureworks.com"));
services.AddHttpClient("contoso", c => c.BaseAddress = new Uri("https://www.contoso.com"));

public class MyController
{
    public MyController(IHttpClientFactory httpClientFactory)
    {
        _adventureWorksClient = httpClientFactory.CreateClient("adventureworks");
        _contosoClient = httpClientFactory.CreateClient("contoso");
    }
}

With keyed DI:

services.AddHttpClient("adventureworks", c => c.BaseAddress = new Uri("https://www.adventureworks.com"));
services.AddHttpClient("contoso", c => c.BaseAddress = new Uri("https://www.contoso.com"));

public class MyController
{
    public MyController(
        [FromKeyedServices("adventureworks")] HttpClient adventureWorksClient,
        [FromKeyedServices("contoso")] HttpClient contosoClient)
    {
        _adventureWorksClient = adventureWorksClient;
        _contosoClient = contosoClient;
    }
}

Also would support multiple typed clients with different names. I think there is validation against doing that today, so need to consider how to change validation to allow it.

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-Extensions-HttpClientFactoryblockingMarks issues that we want to fast track in order to unblock other important workenhancementProduct code improvement that does NOT require public API changes/additionsin-prThere is an active PR which will close this issue when it is merged

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions