Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[HttpClientFactory] Revert workaround for empty name HttpClient fallback #106269

Merged
merged 4 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -645,16 +645,51 @@ public static IHttpClientBuilder ConfigureAdditionalHttpMessageHandlers(this IHt
return builder;
}

#pragma warning disable 1591 // TODO: Document this API. https://github.com/dotnet/runtime/issues/105974
/// <summary>
/// Registers named <see cref="HttpClient"/> and the related handler pipeline <see cref="HttpMessageHandler"/> as keyed
/// services with the client's name as the key, and a lifetime provided in the <paramref name="lifetime" /> parameter.
/// By default, the lifetime is <see cref="ServiceLifetime.Scoped"/>.
/// </summary>
/// <param name="builder">The <see cref="IHttpClientBuilder"/>.</param>
/// <param name="lifetime">Lifetime of the keyed services registered.</param>
/// <returns>An <see cref="IHttpClientBuilder"/> that can be used to configure the client.</returns>
/// <remarks>
/// <para>
/// A named client resolved from DI as a keyed service will behave similarly to a client you would create with <see cref="IHttpClientFactory.CreateClient(string)"/>. This
/// means that the client will continue reusing the same <see cref="HttpMessageHandler"/> instance for the duration of <see cref="HttpClientFactoryOptions.HandlerLifetime"/>,
/// and it will continue to use the separate, handler's DI scope instead of the scope it was resolved from.
/// </para>
/// <para>
/// WARNING: Registering the client as a keyed <see cref="ServiceLifetime.Transient"/> service will lead to the <see cref="HttpClient"/> and <see cref="HttpMessageHandler"/>
/// instances being captured by DI as both implement <see cref="IDisposable"/>. This might lead to memory leaks if the client is resolved multiple times within a
/// <see cref="ServiceLifetime.Singleton"/> service.
Comment on lines +663 to +665
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems pretty sad.

However, this isn't specific to the IHttpClientFactory, and the same issue exists with any disposable service, right? It's just a new foot gun for us since we couldn't be transient before?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep 🥲 https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines#disposable-transient-services-captured-by-container

But there is hope that this might be finally addressed (in some way) in 10.0 (this was discussed during API review, as it was one of the main blockers for keyed-by-default). Also see #89755 (comment). Tracking issue: #36461 but we'd need to also create an additional one for per-service-descriptor opt-out.

/// </para>
/// <para>
/// WARNING: In case of (1) a keyed <see cref="ServiceLifetime.Singleton"/> <see cref="HttpClient"/> registration, or (2) a keyed <see cref="ServiceLifetime.Transient"/>
/// <see cref="HttpClient"/> injected into a <see cref="ServiceLifetime.Singleton"/> service, or (3) long-running application scopes,
/// the <see cref="HttpClient"/> instances will get captured by a singleton or a long-running scope, so they will NOT be able to participate in the handler rotation,
/// which can result in the loss of DNS changes. (This is a similar issue to the one with Typed Clients, that are registered as <see cref="ServiceLifetime.Transient"/> services.)
/// </para>
/// <para>
/// If called twice with for a builder with the same name, the lifetime of the keyed service will be updated to the latest used <see cref="ServiceLifetime"/> value.
/// </para>
/// <para>
/// If called for a typed client, only the related named client and handler will be registered as keyed. The typed client itself will continue to be registered as
/// a transient service.
/// </para>
/// <para>
/// If used in conjuction with <see cref="HttpClientFactoryServiceCollectionExtensions.ConfigureHttpClientDefaults(IServiceCollection, Action{IHttpClientBuilder})"/>,
/// the key <see cref="KeyedService.AnyKey"/> is used, so any named <see cref="HttpClient"/> instance will be resolvable as a keyed service (unless explicitly opted-out
/// from the keyed registration via <see cref="RemoveAsKeyed"/>).
/// </para>
/// </remarks>
public static IHttpClientBuilder AddAsKeyed(this IHttpClientBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Scoped)
{
ThrowHelper.ThrowIfNull(builder);

string? name = builder.Name;
IServiceCollection services = builder.Services;
HttpClientMappingRegistry registry = GetMappingRegistry(services);

UpdateEmptyNameHttpClient(services, registry);
HttpClientMappingRegistry registry = services.GetMappingRegistry();

if (name == null)
{
Expand All @@ -678,15 +713,25 @@ public static IHttpClientBuilder AddAsKeyed(this IHttpClientBuilder builder, Ser
return builder;
}

/// <summary>
/// Removes the keyed registrations for the named <see cref="HttpClient"/> and <see cref="HttpMessageHandler"/>.
/// </summary>
/// <param name="builder">The <see cref="IHttpClientBuilder"/>.</param>
/// <returns>An <see cref="IHttpClientBuilder"/> that can be used to configure the client.</returns>
/// <remarks>
/// <para>
/// If used in conjuction with <see cref="HttpClientFactoryServiceCollectionExtensions.ConfigureHttpClientDefaults(IServiceCollection, Action{IHttpClientBuilder})"/>,
/// it will only affect the previous "global" <see cref="KeyedService.AnyKey"/> registration, and won't affect the clients registered for a specific name
/// with <see cref="AddAsKeyed"/>.
/// </para>
/// </remarks>
public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder)
{
ThrowHelper.ThrowIfNull(builder);

string? name = builder.Name;
IServiceCollection services = builder.Services;
HttpClientMappingRegistry registry = GetMappingRegistry(services);

UpdateEmptyNameHttpClient(services, registry);
HttpClientMappingRegistry registry = services.GetMappingRegistry();

if (name == null)
{
Expand All @@ -704,32 +749,6 @@ public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder)

return builder;
}
#pragma warning restore 1591

// workaround for https://github.com/dotnet/runtime/issues/102654
private static void UpdateEmptyNameHttpClient(IServiceCollection services, HttpClientMappingRegistry registry)
{
if (registry.EmptyNameHttpClientDescriptor is not null)
{
bool removed = services.Remove(registry.EmptyNameHttpClientDescriptor);

if (removed)
{
// trying to add it as keyed instead
if (!registry.KeyedLifetimeMap.ContainsKey(string.Empty))
{
var clientLifetime = new HttpClientKeyedLifetime(string.Empty, ServiceLifetime.Transient);
registry.KeyedLifetimeMap[string.Empty] = clientLifetime;
clientLifetime.AddRegistration(services);
}
}
}

if (services.Any(sd => sd.ServiceType == typeof(HttpClient) && sd.ServiceKey is null))
{
throw new InvalidOperationException($"{nameof(AddAsKeyed)} isn't supported when {nameof(HttpClient)} is registered as a service.");
}
}

// See comments on HttpClientMappingRegistry.
private static void ReserveClient(IHttpClientBuilder builder, Type type, string name, bool validateSingleType)
Expand Down Expand Up @@ -763,7 +782,11 @@ private static void ReserveClient(IHttpClientBuilder builder, Type type, string
}
}

private static HttpClientMappingRegistry GetMappingRegistry(IServiceCollection services)
=> HttpClientFactoryServiceCollectionExtensions.GetMappingRegistry(services);
internal static HttpClientMappingRegistry GetMappingRegistry(this IServiceCollection services)
{
var registry = (HttpClientMappingRegistry?)services.Single(sd => sd.ServiceType == typeof(HttpClientMappingRegistry)).ImplementationInstance;
Debug.Assert(registry != null);
return registry;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ public static IServiceCollection AddHttpClient(this IServiceCollection services)
services.TryAddSingleton(new DefaultHttpClientConfigurationTracker());

// Register default client as HttpClient
TryAddEmptyNameHttpClient(services);
services.TryAddTransient(s =>
{
return s.GetRequiredService<IHttpClientFactory>().CreateClient(string.Empty);
});

return services;
}
Expand Down Expand Up @@ -831,34 +834,5 @@ public static IHttpClientBuilder AddHttpClient<TClient, TImplementation>(this IS
builder.AddTypedClient<TClient>(factory);
return builder;
}

internal static HttpClientMappingRegistry GetMappingRegistry(IServiceCollection services)
{
var registry = (HttpClientMappingRegistry?)services.Single(sd => sd.ServiceType == typeof(HttpClientMappingRegistry)).ImplementationInstance;
Debug.Assert(registry != null);
return registry;
}

private static void TryAddEmptyNameHttpClient(IServiceCollection services)
{
HttpClientMappingRegistry mappingRegistry = GetMappingRegistry(services);

if (mappingRegistry.EmptyNameHttpClientDescriptor is not null)
{
return;
}

if (services.Any(sd => sd.ServiceType == typeof(HttpClient) && sd.ServiceKey is null))
{
return;
}

mappingRegistry.EmptyNameHttpClientDescriptor = ServiceDescriptor.Transient(s =>
{
return s.GetRequiredService<IHttpClientFactory>().CreateClient(string.Empty);
});

services.Add(mappingRegistry.EmptyNameHttpClientDescriptor);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,5 @@ internal sealed class HttpClientMappingRegistry
public Dictionary<string, HttpClientKeyedLifetime> KeyedLifetimeMap { get; } = new();

public HttpClientKeyedLifetime? DefaultKeyedLifetime { get; set; }

public ServiceDescriptor? EmptyNameHttpClientDescriptor { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,51 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Net.Http;
using Xunit;

namespace Microsoft.Extensions.DependencyInjection
{
public partial class HttpClientKeyedRegistrationTest
{
[Fact]
public void AddAsKeyed_EmptyNameHttpClientUpdated() // test for workaround for https://github.com/dotnet/runtime/issues/102654
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no value in keeping these tests?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None -- they were testing specifically the workaround logic that was removed.

{
var serviceCollection = new ServiceCollection();

serviceCollection.AddHttpClient();

var emptyNameDescriptor = Assert.Single(serviceCollection, d => d.ServiceType == typeof(HttpClient));
Assert.False(emptyNameDescriptor.IsKeyedService);

serviceCollection.AddHttpClient(Test).AddAsKeyed();

emptyNameDescriptor = Assert.Single(serviceCollection, d => d.ServiceType == typeof(HttpClient) && !(d.ServiceKey is string s && s == Test));
Assert.True(emptyNameDescriptor.IsKeyedService);
Assert.Equal(string.Empty, emptyNameDescriptor.ServiceKey);
}

[Fact]
public void AddAsKeyed_NonFactoryHttpClientAdded_Throws() // test for workaround for https://github.com/dotnet/runtime/issues/102654
{
var serviceCollection = new ServiceCollection();

serviceCollection.AddSingleton(new HttpClient());

var emptyNameDescriptor = Assert.Single(serviceCollection, d => d.ServiceType == typeof(HttpClient));
Assert.False(emptyNameDescriptor.IsKeyedService);
Assert.Equal(ServiceLifetime.Singleton, emptyNameDescriptor.Lifetime);

var builder = serviceCollection.AddHttpClient(Test);

emptyNameDescriptor = Assert.Single(serviceCollection, d => d.ServiceType == typeof(HttpClient));
Assert.False(emptyNameDescriptor.IsKeyedService);
Assert.Equal(ServiceLifetime.Singleton, emptyNameDescriptor.Lifetime);

Assert.Throws<InvalidOperationException>(() => builder.AddAsKeyed());
}

[Fact]
public void AddAsKeyed_ScopedLifetime()
{
Expand Down Expand Up @@ -150,10 +112,10 @@ public void RemoveAsKeyed_PerName_AnyKeyDescriptorRemains()
}

private static bool IsKeyedClientDescriptor(ServiceDescriptor descriptor)
=> descriptor.ServiceType == typeof(HttpClient) && descriptor.IsKeyedService && (descriptor.ServiceKey is not string name || name.Length > 0);
=> descriptor.IsKeyedService && descriptor.ServiceType == typeof(HttpClient);

private static bool IsKeyedHandlerDescriptor(ServiceDescriptor descriptor)
=> descriptor.ServiceType == typeof(HttpMessageHandler) && descriptor.IsKeyedService && (descriptor.ServiceKey is not string name || name.Length > 0);
=> descriptor.IsKeyedService && descriptor.ServiceType == typeof(HttpMessageHandler);

private static void AssertSingleKeyedClientDescriptor(IServiceCollection services, ServiceLifetime lifetime, object key)
{
Expand Down
Loading