diff --git a/src/libraries/Microsoft.Extensions.Http/ref/Microsoft.Extensions.Http.cs b/src/libraries/Microsoft.Extensions.Http/ref/Microsoft.Extensions.Http.cs index 319806f2ae8e5..4f2fb8ad185de 100644 --- a/src/libraries/Microsoft.Extensions.Http/ref/Microsoft.Extensions.Http.cs +++ b/src/libraries/Microsoft.Extensions.Http/ref/Microsoft.Extensions.Http.cs @@ -21,6 +21,7 @@ public static partial class HttpClientBuilderExtensions public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder RedactLoggedHeaders(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, System.Collections.Generic.IEnumerable redactedLoggedHeaderNames) { throw null; } public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder RedactLoggedHeaders(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, System.Func shouldRedactHeaderValue) { throw null; } public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder SetHandlerLifetime(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, System.TimeSpan handlerLifetime) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder SetPreserveExistingScope(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, bool preserveExistingScope) { throw null; } } public static partial class HttpClientFactoryServiceCollectionExtensions { @@ -61,6 +62,7 @@ public HttpClientFactoryOptions() { } public System.Collections.Generic.IList> HttpMessageHandlerBuilderActions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } public System.Func ShouldRedactHeaderValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public bool SuppressHandlerScope { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool PreserveExistingScope { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } } public abstract partial class HttpMessageHandlerBuilder { @@ -112,8 +114,16 @@ public partial interface IHttpClientFactory { System.Net.Http.HttpClient CreateClient(string name); } + public partial interface IScopedHttpClientFactory + { + System.Net.Http.HttpClient CreateClient(string name); + } public partial interface IHttpMessageHandlerFactory { System.Net.Http.HttpMessageHandler CreateHandler(string name); } + public partial interface IScopedHttpMessageHandlerFactory + { + System.Net.Http.HttpMessageHandler CreateHandler(string name); + } } diff --git a/src/libraries/Microsoft.Extensions.Http/src/ActiveHandlerTrackingEntry.cs b/src/libraries/Microsoft.Extensions.Http/src/ActiveHandlerTrackingEntry.cs index c99ee1f2250cd..bff832f60f19b 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/ActiveHandlerTrackingEntry.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/ActiveHandlerTrackingEntry.cs @@ -22,6 +22,7 @@ internal class ActiveHandlerTrackingEntry public ActiveHandlerTrackingEntry( string name, LifetimeTrackingHttpMessageHandler handler, + bool isPrimary, IServiceScope scope, TimeSpan lifetime) { @@ -29,6 +30,7 @@ public ActiveHandlerTrackingEntry( Handler = handler; Scope = scope; Lifetime = lifetime; + IsPrimary = isPrimary; _lock = new object(); } @@ -41,6 +43,8 @@ public ActiveHandlerTrackingEntry( public IServiceScope Scope { get; } + public bool IsPrimary { get; } + public void StartExpiryTimer(TimerCallback callback) { if (Lifetime == Timeout.InfiniteTimeSpan) diff --git a/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs b/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs index 059111a2e66b2..6449b4b742cbd 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs @@ -23,7 +23,6 @@ internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandle private readonly IServiceScopeFactory _scopeFactory; private readonly IOptionsMonitor _optionsMonitor; private readonly IHttpMessageHandlerBuilderFilter[] _filters; - private readonly Func> _entryFactory; // Default time of 10s for cleanup seems reasonable. // Quick math: @@ -43,11 +42,10 @@ internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandle // Collection of 'active' handlers. // - // Using lazy for synchronization to ensure that only one instance of HttpMessageHandler is created + // Using ReaderWriterLockSlim for synchronization to ensure that only one instance of HttpMessageHandler is created // for each name. - // - // internal for tests - internal readonly ConcurrentDictionary> _activeHandlers; + private readonly Dictionary _activeHandlers; + private readonly ReaderWriterLockSlim messageHandlerLock = new ReaderWriterLockSlim(); // Collection of 'expired' but not yet disposed handlers. // @@ -97,15 +95,8 @@ public DefaultHttpClientFactory( _logger = loggerFactory.CreateLogger(); - // case-sensitive because named options is. - _activeHandlers = new ConcurrentDictionary>(StringComparer.Ordinal); - _entryFactory = (name) => - { - return new Lazy(() => - { - return CreateHandlerEntry(name); - }, LazyThreadSafetyMode.ExecutionAndPublication); - }; + // same comparer as for named options. + _activeHandlers = new Dictionary(StringComparer.Ordinal); _expiredHandlers = new ConcurrentQueue(); _expiryCallback = ExpiryTimer_Tick; @@ -115,14 +106,30 @@ public DefaultHttpClientFactory( } public HttpClient CreateClient(string name) + { + HttpClientFactoryOptions options = _optionsMonitor.Get(name); + if (options.PreserveExistingScope) + { + string message = SR.Format( + SR.PreserveExistingScope_CannotUseWithFactory, + options.PreserveExistingScope, + nameof(IHttpClientFactory), + nameof(IScopedHttpClientFactory)); + throw new InvalidOperationException(message); + } + + return CreateClient(name, null); + } + + public HttpClient CreateClient(string name, IServiceProvider services) { if (name == null) { throw new ArgumentNullException(nameof(name)); } - HttpMessageHandler handler = CreateHandler(name); - var client = new HttpClient(handler, disposeHandler: false); + HttpMessageHandler handler = CreateHandler(name, services); + var client = new HttpClient(handler); HttpClientFactoryOptions options = _optionsMonitor.Get(name); for (int i = 0; i < options.HttpClientActions.Count; i++) @@ -134,22 +141,108 @@ public HttpClient CreateClient(string name) } public HttpMessageHandler CreateHandler(string name) + { + HttpClientFactoryOptions options = _optionsMonitor.Get(name); + if (options.PreserveExistingScope) + { + string message = SR.Format( + SR.PreserveExistingScope_CannotUseWithFactory, + options.PreserveExistingScope, + nameof(IHttpMessageHandlerFactory), + nameof(IScopedHttpMessageHandlerFactory)); + throw new InvalidOperationException(message); + } + + return CreateHandler(name, null); + } + + public HttpMessageHandler CreateHandler(string name, IServiceProvider services) { if (name == null) { throw new ArgumentNullException(nameof(name)); } - ActiveHandlerTrackingEntry entry = _activeHandlers.GetOrAdd(name, _entryFactory).Value; + ActiveHandlerTrackingEntry entry; + LifetimeTrackingHttpMessageHandler? topHandler = null; + + try + { + messageHandlerLock.EnterUpgradeableReadLock(); + + if (!_activeHandlers.TryGetValue(name, out entry)) + { + try + { + messageHandlerLock.EnterWriteLock(); + + if (!_activeHandlers.TryGetValue(name, out entry)) + { + var createEntryResult = CreateHandlerEntry(name, services); + entry = createEntryResult.Entry; + topHandler = createEntryResult.TopHandler; + _activeHandlers.Add(name, entry); + } + } + finally + { + messageHandlerLock.ExitWriteLock(); + } + } + } + finally + { + messageHandlerLock.ExitUpgradeableReadLock(); + } StartHandlerEntryTimer(entry); - return entry.Handler; + if (!entry.IsPrimary) + { + return entry.Handler; + } + + if (services == null) // created in manual scope + { + services = entry.Scope?.ServiceProvider; + } + + if (topHandler == null) + { + try + { + messageHandlerLock.EnterWriteLock(); + + topHandler = BuildTopHandler(name, entry.Handler, services); + } + finally + { + messageHandlerLock.ExitWriteLock(); + } + } + + var expired = new ExpiredHandlerTrackingEntry(name, topHandler, null); + _expiredHandlers.Enqueue(expired); // we expire the top chain right away. it will be cleared after it gets GC'ed + return topHandler; } - // Internal for tests - internal ActiveHandlerTrackingEntry CreateHandlerEntry(string name) + private HandlerEntryData CreateHandlerEntry(string name, IServiceProvider? scopedServices) { + Debug.Assert(messageHandlerLock.IsWriteLockHeld); + + HttpClientFactoryOptions options = _optionsMonitor.Get(name); + if (!options.PreserveExistingScope || options.SuppressHandlerScope || scopedServices == null) + { + return CreateHandlerEntryInManualScope(name); + } + + return CreateHandlerEntryCore(name, scopedServices, null, options); + } + + private HandlerEntryData CreateHandlerEntryInManualScope(string name) + { + Debug.Assert(messageHandlerLock.IsWriteLockHeld); + IServiceProvider services = _services; var scope = (IServiceScope)null; @@ -162,57 +255,139 @@ internal ActiveHandlerTrackingEntry CreateHandlerEntry(string name) try { - HttpMessageHandlerBuilder builder = services.GetRequiredService(); - builder.Name = name; + return CreateHandlerEntryCore(name, services, scope, options); + } + catch + { + // If something fails while creating the handler, dispose the services. + scope?.Dispose(); + throw; + } + } - // This is similar to the initialization pattern in: - // https://github.com/aspnet/Hosting/blob/e892ed8bbdcd25a0dafc1850033398dc57f65fe1/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs#L188 - Action configure = Configure; - for (int i = _filters.Length - 1; i >= 0; i--) - { - configure = _filters[i].Configure(configure); - } + private HandlerEntryData CreateHandlerEntryCore(string name, IServiceProvider services, IServiceScope? scope, HttpClientFactoryOptions options) + { + Debug.Assert(messageHandlerLock.IsWriteLockHeld); - configure(builder); + if (options.PreserveExistingScope && options.SuppressHandlerScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_SuppressHandlerScope_BothTrueIsInvalid); + } + + // fast track if no one accessed primary handler config + if (options.PreserveExistingScope && !options._primaryHandlerExposed) + { + var primaryHandler = new LifetimeTrackingHttpMessageHandler(new HttpClientHandler()); + var activeEntry = new ActiveHandlerTrackingEntry(name, primaryHandler, true, scope, options.HandlerLifetime); + return new HandlerEntryData(activeEntry); + } + + HttpMessageHandlerBuilder builder = services.GetRequiredService(); + builder.Name = name; + + ConfigureBuilder(builder, options); + + LifetimeTrackingHttpMessageHandler handler; + bool isPrimary; + LifetimeTrackingHttpMessageHandler topHandler; + + if (options.PreserveExistingScope && builder.PrimaryHandlerChanged) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); + } + + if (options.PreserveExistingScope) + { + // to stop dispose on primary handler when the chain is disposed + handler = new LifetimeTrackingHttpMessageHandler(new HttpClientHandler()); + isPrimary = true; // Wrap the handler so we can ensure the inner handler outlives the outer handler. - var handler = new LifetimeTrackingHttpMessageHandler(builder.Build()); + topHandler = new LifetimeTrackingHttpMessageHandler(builder.Build(handler)); + } + else + { + var topHandlerInner = builder.Build(); - // Note that we can't start the timer here. That would introduce a very very subtle race condition - // with very short expiry times. We need to wait until we've actually handed out the handler once - // to start the timer. - // - // Otherwise it would be possible that we start the timer here, immediately expire it (very short - // timer) and then dispose it without ever creating a client. That would be bad. It's unlikely - // this would happen, but we want to be sure. - return new ActiveHandlerTrackingEntry(name, handler, scope, options.HandlerLifetime); + // Wrap the handler so we can ensure the inner handler outlives the outer handler. + handler = new LifetimeTrackingHttpMessageHandler(topHandlerInner); + isPrimary = false; + topHandler = null; + } - void Configure(HttpMessageHandlerBuilder b) - { - for (int i = 0; i < options.HttpMessageHandlerBuilderActions.Count; i++) - { - options.HttpMessageHandlerBuilderActions[i](b); - } - } + // Note that we can't start the timer here. That would introduce a very very subtle race condition + // with very short expiry times. We need to wait until we've actually handed out the handler once + // to start the timer. + // + // Otherwise it would be possible that we start the timer here, immediately expire it (very short + // timer) and then dispose it without ever creating a client. That would be bad. It's unlikely + // this would happen, but we want to be sure. + var entry = new ActiveHandlerTrackingEntry(name, handler, isPrimary, scope, options.HandlerLifetime); + return new HandlerEntryData(entry, topHandler); + } + + private LifetimeTrackingHttpMessageHandler BuildTopHandler(string name, HttpMessageHandler primaryHandler, IServiceProvider? scopedServices) + { + Debug.Assert(messageHandlerLock.IsWriteLockHeld); + + IServiceProvider services = scopedServices ?? _services; + + HttpMessageHandlerBuilder builder = services.GetRequiredService(); + builder.Name = name; + + HttpClientFactoryOptions options = _optionsMonitor.Get(name); + ConfigureBuilder(builder, options); + + if (builder.PrimaryHandlerChanged) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); } - catch + + return new LifetimeTrackingHttpMessageHandler(builder.Build(primaryHandler)); + } + + private void ConfigureBuilder(HttpMessageHandlerBuilder builder, HttpClientFactoryOptions options) + { + // This is similar to the initialization pattern in: + // https://github.com/aspnet/Hosting/blob/e892ed8bbdcd25a0dafc1850033398dc57f65fe1/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs#L188 + Action configure = Configure; + for (int i = _filters.Length - 1; i >= 0; i--) { - // If something fails while creating the handler, dispose the services. - scope?.Dispose(); - throw; + configure = _filters[i].Configure(configure); + } + + configure(builder); + + void Configure(HttpMessageHandlerBuilder b) + { + for (int i = 0; i < options.HttpMessageHandlerBuilderActions.Count; i++) + { + options.HttpMessageHandlerBuilderActions[i](b); + } } } // Internal for tests - internal void ExpiryTimer_Tick(object state) + internal virtual void ExpiryTimer_Tick(object state) { var active = (ActiveHandlerTrackingEntry)state; - // The timer callback should be the only one removing from the active collection. If we can't find - // our entry in the collection, then this is a bug. - bool removed = _activeHandlers.TryRemove(active.Name, out Lazy found); - Debug.Assert(removed, "Entry not found. We should always be able to remove the entry"); - Debug.Assert(object.ReferenceEquals(active, found.Value), "Different entry found. The entry should not have been replaced"); + try + { + messageHandlerLock.EnterWriteLock(); + + // The timer callback should be the only one removing from the active collection. If we can't find + // our entry in the collection, then this is a bug. + bool entryExists = _activeHandlers.TryGetValue(active.Name, out ActiveHandlerTrackingEntry found); + Debug.Assert(entryExists, "Entry not found. We should always be able to remove the entry"); + Debug.Assert(object.ReferenceEquals(active, found), "Different entry found. The entry should not have been replaced"); + + _activeHandlers.Remove(active.Name); + } + finally + { + messageHandlerLock.ExitWriteLock(); + } // At this point the handler is no longer 'active' and will not be handed out to any new clients. // However we haven't dropped our strong reference to the handler, so we can't yet determine if @@ -330,6 +505,40 @@ internal void CleanupTimer_Tick() } } + // internal for tests + internal ActiveHandlerTrackingEntry? GetActiveEntry(string name) + { + messageHandlerLock.EnterReadLock(); + + try + { + if (_activeHandlers.TryGetValue(name, out var entry)) + { + return entry; + } + return null; + } + finally + { + messageHandlerLock.ExitReadLock(); + } + } + + // internal for tests + internal int GetActiveEntryCount() + { + messageHandlerLock.EnterReadLock(); + + try + { + return _activeHandlers.Count; + } + finally + { + messageHandlerLock.ExitReadLock(); + } + } + private static class Log { public static class EventIds @@ -381,5 +590,21 @@ public static void HandlerExpired(ILogger logger, string clientName, TimeSpan li _handlerExpired(logger, lifetime.TotalMilliseconds, clientName, null); } } + + private class HandlerEntryData + { + public ActiveHandlerTrackingEntry Entry { get; } + public LifetimeTrackingHttpMessageHandler? TopHandler { get; } + + public HandlerEntryData(ActiveHandlerTrackingEntry entry) : this(entry, null) + { + } + + public HandlerEntryData(ActiveHandlerTrackingEntry entry, LifetimeTrackingHttpMessageHandler? topHandler) + { + Entry = entry; + TopHandler = topHandler; + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpMessageHandlerBuilder.cs b/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpMessageHandlerBuilder.cs index c15bce2ba57b9..2ca4a1d267cfd 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpMessageHandlerBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpMessageHandlerBuilder.cs @@ -30,7 +30,27 @@ public override string Name } } - public override HttpMessageHandler PrimaryHandler { get; set; } = new HttpClientHandler(); + private bool _primaryHandlerChanged; + internal override bool PrimaryHandlerChanged => _primaryHandlerChanged; + + private HttpMessageHandler _primaryHandler; + public override HttpMessageHandler PrimaryHandler + { + get + { + if (_primaryHandler == null && !_primaryHandlerChanged) + { + _primaryHandler = new HttpClientHandler(); // Backward-compatibility + } + _primaryHandlerChanged = true; // Someone accessed PrimaryHandler. Its properties might be changed. + return _primaryHandler; + } + set + { + _primaryHandler = value; + _primaryHandlerChanged = true; + } + } public override IList AdditionalHandlers { get; } = new List(); diff --git a/src/libraries/Microsoft.Extensions.Http/src/DefaultScopedHttpClientFactory.cs b/src/libraries/Microsoft.Extensions.Http/src/DefaultScopedHttpClientFactory.cs new file mode 100644 index 0000000000000..a465682cc71ac --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Http/src/DefaultScopedHttpClientFactory.cs @@ -0,0 +1,86 @@ +// 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.Collections.Concurrent; +using System.Net.Http; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Http +{ + internal class DefaultScopedHttpClientFactory : IScopedHttpClientFactory, IScopedHttpMessageHandlerFactory + { + // internal for tests + internal DefaultHttpClientFactory _singletonFactory; + private IServiceProvider _services; + private IOptionsMonitor _optionsMonitor; + + // cache for creating a chain only once per scope. same comparer as for named options. + private ConcurrentDictionary _cache = new ConcurrentDictionary(StringComparer.Ordinal); + + public DefaultScopedHttpClientFactory(DefaultHttpClientFactory factory, IServiceProvider services, IOptionsMonitor optionsMonitor) + { + _singletonFactory = factory; + _services = services; + _optionsMonitor = optionsMonitor; + } + + public HttpClient CreateClient(string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + HttpClientFactoryOptions options = _optionsMonitor.Get(name); + if (!options.PreserveExistingScope) + { + string message = SR.Format( + SR.PreserveExistingScope_CannotUseWithFactory, + options.PreserveExistingScope, + nameof(IScopedHttpClientFactory), + nameof(IHttpClientFactory)); + throw new InvalidOperationException(message); + } + if (options.SuppressHandlerScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_SuppressHandlerScope_BothTrueIsInvalid); + } + + HttpMessageHandler handler = CreateHandler(name); + var client = new HttpClient(handler); + for (int i = 0; i < options.HttpClientActions.Count; i++) + { + options.HttpClientActions[i](client); + } + + return client; + } + + public HttpMessageHandler CreateHandler(string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + HttpClientFactoryOptions options = _optionsMonitor.Get(name); + if (!options.PreserveExistingScope) + { + string message = SR.Format( + SR.PreserveExistingScope_CannotUseWithFactory, + options.PreserveExistingScope, + nameof(IScopedHttpMessageHandlerFactory), + nameof(IHttpMessageHandlerFactory)); + throw new InvalidOperationException(message); + } + if (options.SuppressHandlerScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_SuppressHandlerScope_BothTrueIsInvalid); + } + + // thread safety of the `valueFactory` param for GetOrAdd is handled by _factory.CreateHandler + return _cache.GetOrAdd(name, s => _singletonFactory.CreateHandler(s, _services)); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs index 8d6ffb703f154..73aa61f103bde 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs @@ -194,7 +194,14 @@ public static IHttpClientBuilder ConfigurePrimaryHttpMessageHandler(this IHttpCl builder.Services.Configure(builder.Name, options => { + if (options.PreserveExistingScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); + } + options.HttpMessageHandlerBuilderActions.Add(b => b.PrimaryHandler = configureHandler()); + options._primaryHandlerExposed = true; + options._primaryHandlerChanged = true; }); return builder; @@ -231,7 +238,14 @@ public static IHttpClientBuilder ConfigurePrimaryHttpMessageHandler(this IHttpCl builder.Services.Configure(builder.Name, options => { + if (options.PreserveExistingScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); + } + options.HttpMessageHandlerBuilderActions.Add(b => b.PrimaryHandler = configureHandler(b.Services)); + options._primaryHandlerExposed = true; + options._primaryHandlerChanged = true; }); return builder; @@ -262,7 +276,14 @@ public static IHttpClientBuilder ConfigurePrimaryHttpMessageHandler(th builder.Services.Configure(builder.Name, options => { + if (options.PreserveExistingScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); + } + options.HttpMessageHandlerBuilderActions.Add(b => b.PrimaryHandler = b.Services.GetRequiredService()); + options._primaryHandlerExposed = true; + options._primaryHandlerChanged = true; }); return builder; @@ -287,7 +308,11 @@ public static IHttpClientBuilder ConfigureHttpMessageHandlerBuilder(this IHttpCl throw new ArgumentNullException(nameof(configureBuilder)); } - builder.Services.Configure(builder.Name, options => options.HttpMessageHandlerBuilderActions.Add(configureBuilder)); + builder.Services.Configure(builder.Name, options => + { + options.HttpMessageHandlerBuilderActions.Add(configureBuilder); + options._primaryHandlerExposed = true; + }); return builder; } @@ -337,8 +362,7 @@ public static IHttpClientBuilder ConfigureHttpMessageHandlerBuilder(this IHttpCl builder.Services.AddTransient(s => { - IHttpClientFactory httpClientFactory = s.GetRequiredService(); - HttpClient httpClient = httpClientFactory.CreateClient(builder.Name); + HttpClient httpClient = s.GetRequiredHttpClient(builder.Name); ITypedHttpClientFactory typedClientFactory = s.GetRequiredService>(); return typedClientFactory.CreateClient(httpClient); @@ -400,8 +424,7 @@ public static IHttpClientBuilder ConfigureHttpMessageHandlerBuilder(this IHttpCl builder.Services.AddTransient(s => { - IHttpClientFactory httpClientFactory = s.GetRequiredService(); - HttpClient httpClient = httpClientFactory.CreateClient(builder.Name); + HttpClient httpClient = s.GetRequiredHttpClient(builder.Name); ITypedHttpClientFactory typedClientFactory = s.GetRequiredService>(); return typedClientFactory.CreateClient(httpClient); @@ -454,8 +477,7 @@ internal static IHttpClientBuilder AddTypedClientCore(this IHttpClientB builder.Services.AddTransient(s => { - IHttpClientFactory httpClientFactory = s.GetRequiredService(); - HttpClient httpClient = httpClientFactory.CreateClient(builder.Name); + HttpClient httpClient = s.GetRequiredHttpClient(builder.Name); return factory(httpClient); }); @@ -517,8 +539,7 @@ internal static IHttpClientBuilder AddTypedClientCore(this IHttpClientB builder.Services.AddTransient(s => { - IHttpClientFactory httpClientFactory = s.GetRequiredService(); - HttpClient httpClient = httpClientFactory.CreateClient(builder.Name); + HttpClient httpClient = s.GetRequiredHttpClient(builder.Name); return factory(httpClient, s); }); @@ -554,6 +575,52 @@ public static IHttpClientBuilder RedactLoggedHeaders(this IHttpClientBuilder bui return builder; } + /// + /// Configures whether the additional message handlers added by + /// will be resolved in the existing DI scope. + /// + /// + /// + /// Configurations with set to `true` can only be used with + /// and . + /// Configurations with set to `false` can only be used with + /// and . + /// + /// + /// If set to `true`, only default primary message handler can be used, + /// it should not be changed by methods like + /// or + /// + /// + /// + /// The . + /// Whether the additional message handlers will be resolved in the existing DI scope + /// The . + public static IHttpClientBuilder SetPreserveExistingScope(this IHttpClientBuilder builder, bool preserveExistingScope) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.Configure(builder.Name, options => + { + if (options.SuppressHandlerScope) + { + throw new InvalidOperationException(SR.PreserveExistingScope_SuppressHandlerScope_BothTrueIsInvalid); + } + + if (options._primaryHandlerChanged) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); + } + + options.PreserveExistingScope = preserveExistingScope; + }); + + return builder; + } + /// /// Sets the collection of HTTP headers names for which values should be redacted before logging. /// @@ -649,5 +716,22 @@ private static void ReserveClient(IHttpClientBuilder builder, Type type, string registry.NamedClientRegistrations[name] = type; } } + + private static HttpClient GetRequiredHttpClient(this IServiceProvider s, string name) + { + var optionsMonitor = s.GetRequiredService>(); + var options = optionsMonitor.Get(name); + + if (options.PreserveExistingScope) + { + IScopedHttpClientFactory scopedFactory = s.GetRequiredService(); + return scopedFactory.CreateClient(name); + } + else + { + IHttpClientFactory httpClientFactory = s.GetRequiredService(); + return httpClientFactory.CreateClient(name); + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientFactoryServiceCollectionExtensions.cs b/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientFactoryServiceCollectionExtensions.cs index b0a34b788e430..7a00f932c9409 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientFactoryServiceCollectionExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientFactoryServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Net.Http; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Http; @@ -37,6 +38,9 @@ public static IServiceCollection AddHttpClient(this IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.TryAddScoped(serviceProvider => serviceProvider.GetRequiredService()); // // Typed Clients @@ -59,6 +63,37 @@ public static IServiceCollection AddHttpClient(this IServiceCollection services) return s.GetRequiredService().CreateClient(string.Empty); }); + // should be executed after all options configured... + services.PostConfigureAll(o => + { + bool customFiltersRegistered = services.Where(d => d.ServiceType == typeof(IHttpMessageHandlerBuilderFilter)) + .Select(d => + { + // copied from + // internal Type Microsoft.Extensions.DependencyInjection.ServiceDescriptor.GetImplementationType() + if (d.ImplementationType != null) + { + return d.ImplementationType; + } + else if (d.ImplementationInstance != null) + { + return d.ImplementationInstance.GetType(); + } + else if (d.ImplementationFactory != null) + { + Type[]? typeArguments = d.ImplementationFactory.GetType().GenericTypeArguments; + return typeArguments[1]; + } + return null; + }) + .Any(implType => implType != typeof(LoggingHttpMessageHandlerBuilderFilter)); + + if (customFiltersRegistered) + { + o._primaryHandlerExposed = true; + } + }); + return services; } diff --git a/src/libraries/Microsoft.Extensions.Http/src/ExpiredHandlerTrackingEntry.cs b/src/libraries/Microsoft.Extensions.Http/src/ExpiredHandlerTrackingEntry.cs index 65537b0f96b74..acaf927cd1563 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/ExpiredHandlerTrackingEntry.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/ExpiredHandlerTrackingEntry.cs @@ -15,12 +15,19 @@ internal class ExpiredHandlerTrackingEntry // IMPORTANT: don't cache a reference to `other` or `other.Handler` here. // We need to allow it to be GC'ed. public ExpiredHandlerTrackingEntry(ActiveHandlerTrackingEntry other) + : this(other.Name, other.Handler, other.Scope) { - Name = other.Name; - Scope = other.Scope; + } + + // IMPORTANT: don't cache a reference to `handler` here. + // We need to allow it to be GC'ed. + internal ExpiredHandlerTrackingEntry(string name, LifetimeTrackingHttpMessageHandler handler, IServiceScope scope) + { + Name = name; + Scope = scope; - _livenessTracker = new WeakReference(other.Handler); - InnerHandler = other.Handler.InnerHandler; + _livenessTracker = new WeakReference(handler); + InnerHandler = handler.InnerHandler; } public bool CanDispose => !_livenessTracker.IsAlive; diff --git a/src/libraries/Microsoft.Extensions.Http/src/HttpClientFactoryOptions.cs b/src/libraries/Microsoft.Extensions.Http/src/HttpClientFactoryOptions.cs index 12ee8585ac0eb..9a151cd74dc7f 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/HttpClientFactoryOptions.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/HttpClientFactoryOptions.cs @@ -98,7 +98,37 @@ public TimeSpan HandlerLifetime /// from dependency injection, such as one registered using /// . /// + /// + /// Options and cannot be both set to `true`. + /// /// public bool SuppressHandlerScope { get; set; } + + /// + /// Gets or sets the value which determines whether the additional message handlers added by + /// + /// will be resolved in the existing DI scope. + /// + /// + /// + /// Default value is `false`. + /// + /// + /// Configurations with set to `true` can only be used with and . + /// Configurations with set to `false` can only be used with and . + /// + /// + /// Options and cannot be both set to `true`. + /// + /// + /// If set to `true`, only default primary message handler can be used, it should not be changed by methods like + /// or + /// + /// + /// + public bool PreserveExistingScope { get; set; } + + internal bool _primaryHandlerChanged; + internal bool _primaryHandlerExposed; } } diff --git a/src/libraries/Microsoft.Extensions.Http/src/HttpMessageHandlerBuilder.cs b/src/libraries/Microsoft.Extensions.Http/src/HttpMessageHandlerBuilder.cs index 0c84e573e8057..118f879c575c1 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/HttpMessageHandlerBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/HttpMessageHandlerBuilder.cs @@ -33,6 +33,8 @@ public abstract class HttpMessageHandlerBuilder /// public abstract HttpMessageHandler PrimaryHandler { get; set; } + internal virtual bool PrimaryHandlerChanged { get; } + /// /// Gets a list of additional instances used to configure an /// pipeline. @@ -61,6 +63,21 @@ public abstract class HttpMessageHandlerBuilder /// public abstract HttpMessageHandler Build(); + internal HttpMessageHandler Build(HttpMessageHandler primaryHandler) + { + if (primaryHandler == null) + { + throw new ArgumentNullException(nameof(primaryHandler)); + } + + if (PrimaryHandlerChanged) + { + throw new InvalidOperationException(SR.PreserveExistingScope_CannotChangePrimaryHandler); + } + + return CreateHandlerPipeline(primaryHandler, AdditionalHandlers); + } + protected internal static HttpMessageHandler CreateHandlerPipeline(HttpMessageHandler primaryHandler, IEnumerable additionalHandlers) { // This is similar to https://github.com/aspnet/AspNetWebStack/blob/master/src/System.Net.Http.Formatting/HttpClientFactory.cs#L58 diff --git a/src/libraries/Microsoft.Extensions.Http/src/IHttpClientFactory.cs b/src/libraries/Microsoft.Extensions.Http/src/IHttpClientFactory.cs index 932256900291e..3240d58b96913 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/IHttpClientFactory.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/IHttpClientFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; namespace System.Net.Http { @@ -13,12 +14,17 @@ namespace System.Net.Http /// A default can be registered in an /// by calling . /// The default will be registered in the service collection as a singleton. + /// To use a configuration with a default , it should have + /// option set to `false`, which currently is a default value, + /// but it may be specified explicitly by calling + /// upon registration. /// public interface IHttpClientFactory { /// /// Creates and configures an instance using the configuration that corresponds - /// to the logical name specified by . + /// to the logical name specified by . The configuration should have + /// set to `false`. /// /// The logical name of the client to create. /// A new instance. diff --git a/src/libraries/Microsoft.Extensions.Http/src/IHttpMessageHandlerFactory.cs b/src/libraries/Microsoft.Extensions.Http/src/IHttpMessageHandlerFactory.cs index df05c4be7331e..15d88c5cc4d0d 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/IHttpMessageHandlerFactory.cs +++ b/src/libraries/Microsoft.Extensions.Http/src/IHttpMessageHandlerFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; namespace System.Net.Http { @@ -13,12 +14,17 @@ namespace System.Net.Http /// A default can be registered in an /// by calling . /// The default will be registered in the service collection as a singleton. + /// To use a configuration with a default , it should have + /// option set to `false`, which currently is a default value, + /// but it may be specified explicitly by calling + /// upon registration. /// public interface IHttpMessageHandlerFactory { /// /// Creates and configures an instance using the configuration that corresponds - /// to the logical name specified by . + /// to the logical name specified by . The configuration should have + /// set to `false`. /// /// The logical name of the message handler to create. /// A new instance. diff --git a/src/libraries/Microsoft.Extensions.Http/src/IScopedHttpClientFactory.cs b/src/libraries/Microsoft.Extensions.Http/src/IScopedHttpClientFactory.cs new file mode 100644 index 0000000000000..318fd8f02b512 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Http/src/IScopedHttpClientFactory.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; + +namespace System.Net.Http +{ + /// + /// A factory abstraction for a component that can create instances with custom + /// configuration for a given logical name with respect to an existing DI scope. + /// + /// + /// A default can be registered in an + /// by calling . + /// The default will be registered in the service collection as a scoped service. + /// To use a configuration with a default , it should have + /// option set to `true` by calling + /// upon registration. + /// + public interface IScopedHttpClientFactory + { + /// + /// Creates and configures an instance using the configuration that corresponds + /// to the logical name specified by . The configuration should have + /// set to `true`. + /// + /// The logical name of the client to create. + /// A new instance. + /// + /// + /// Each call to is guaranteed to return a new + /// instance. It is generally not necessary to dispose of the as the + /// tracks and disposes resources used by the . + /// + /// + /// Callers are also free to mutate the returned instance's public properties + /// as desired. + /// + /// + HttpClient CreateClient(string name); + } +} diff --git a/src/libraries/Microsoft.Extensions.Http/src/IScopedHttpMessageHandlerFactory.cs b/src/libraries/Microsoft.Extensions.Http/src/IScopedHttpMessageHandlerFactory.cs new file mode 100644 index 0000000000000..0cb59133af856 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Http/src/IScopedHttpMessageHandlerFactory.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; + +namespace System.Net.Http +{ + /// + /// A factory abstraction for a component that can create instances with custom + /// configuration for a given logical name within an existing DI Scope. + /// + /// + /// A default can be registered in an + /// by calling . + /// The default will be registered in the service collection as a scoped service. + /// To use a configuration with a default , it should have + /// option set to `true` by calling + /// upon registration. + /// + public interface IScopedHttpMessageHandlerFactory + { + /// + /// Creates and configures an instance using the configuration that corresponds + /// to the logical name specified by . The configuration should have + /// set to `true`. + /// + /// The logical name of the message handler to create. + /// A new instance. + /// + /// + /// The default implementation may cache the underlying + /// instances to improve performance. + /// + /// + /// The default implementation also manages the lifetime of the + /// handler created, so disposing of the returned by this method may + /// have no effect. + /// + /// + HttpMessageHandler CreateHandler(string name); + } +} diff --git a/src/libraries/Microsoft.Extensions.Http/src/Microsoft.Extensions.Http.csproj b/src/libraries/Microsoft.Extensions.Http/src/Microsoft.Extensions.Http.csproj index f8382783997e5..452aa14f17974 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/Microsoft.Extensions.Http.csproj +++ b/src/libraries/Microsoft.Extensions.Http/src/Microsoft.Extensions.Http.csproj @@ -1,4 +1,4 @@ - + netstandard2.0;net461 diff --git a/src/libraries/Microsoft.Extensions.Http/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Http/src/Resources/Strings.resx index 04c2a32b8ad2f..786bb07a67ada 100644 --- a/src/libraries/Microsoft.Extensions.Http/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Http/src/Resources/Strings.resx @@ -134,4 +134,16 @@ The handler lifetime must be at least 1 second. + + The configuration with 'PreserveExistingScope'={0} cannot be used with '{1}'. Use '{2}' instead. + 0 = true -OR- false +1 = IHttpClientFactory/IHttpMessageHandlerFactory -OR- IScopedHttpClientFactory/IScopedHttpMessageHandlerFactory +2 = IScopedHttpClientFactory/IScopedHttpMessageHandlerFactory -OR- IHttpClientFactory/IHttpMessageHandlerFactory + + + The configuration with both 'PreserveExistingScope'=true and 'SuppressHandlerScope'=true is invalid. + + + Cannot change PrimaryHandler for the configuration with 'PreserveExistingScope'=true. + \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DefaultHttpClientFactoryTest.cs b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DefaultHttpClientFactoryTest.cs index 72d89b17f8ab5..c66c34c3d5a77 100644 --- a/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DefaultHttpClientFactoryTest.cs +++ b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DefaultHttpClientFactoryTest.cs @@ -232,7 +232,8 @@ public void Factory_CreateClient_FiltersCanDecorateBuilder() }); // Act - var handler = (HttpMessageHandler)factory.CreateHandlerEntry("github").Handler; + var client = factory.CreateClient("github"); + HttpMessageHandler handler = factory.GetActiveEntry("github").Handler; // Assert // @@ -263,7 +264,8 @@ public async Task Factory_CreateClient_WithExpiry_CanExpire() var client1 = factory.CreateClient("github"); // Assert - 1 - var activeEntry1 = Assert.Single(factory._activeHandlers).Value.Value; + Assert.Equal(1, factory.GetActiveEntryCount()); + var activeEntry1 = factory.GetActiveEntry("github"); Assert.Equal("github", activeEntry1.Name); Assert.Equal(TimeSpan.FromMinutes(2), activeEntry1.Lifetime); Assert.NotNull(activeEntry1.Handler); @@ -274,7 +276,7 @@ public async Task Factory_CreateClient_WithExpiry_CanExpire() await expiryTask; // Assert - 2 - Assert.Empty(factory._activeHandlers); + Assert.Equal(0, factory.GetActiveEntryCount()); Assert.True(factory.CleanupTimerStarted.IsSet, "Cleanup timer started"); var expiredEntry1 = Assert.Single(factory._expiredHandlers); @@ -284,7 +286,8 @@ public async Task Factory_CreateClient_WithExpiry_CanExpire() var client2 = factory.CreateClient("github"); // Assert - 3 - var activeEntry2 = Assert.Single(factory._activeHandlers).Value.Value; + Assert.Equal(1, factory.GetActiveEntryCount()); + var activeEntry2 = factory.GetActiveEntry("github"); Assert.Equal("github", activeEntry1.Name); Assert.Equal(TimeSpan.FromMinutes(2), activeEntry1.Lifetime); Assert.NotNull(activeEntry1.Handler); @@ -306,7 +309,8 @@ public async Task Factory_CreateClient_WithExpiry_HandlerCanBeReusedBeforeExpiry var client1 = factory.CreateClient("github"); // Assert - 1 - var activeEntry1 = Assert.Single(factory._activeHandlers).Value.Value; + Assert.Equal(1, factory.GetActiveEntryCount()); + var activeEntry1 = factory.GetActiveEntry("github"); Assert.Equal("github", activeEntry1.Name); Assert.Equal(TimeSpan.FromMinutes(2), activeEntry1.Lifetime); Assert.NotNull(activeEntry1.Handler); @@ -315,7 +319,9 @@ public async Task Factory_CreateClient_WithExpiry_HandlerCanBeReusedBeforeExpiry var client2 = factory.CreateClient("github"); // Assert - 2 - Assert.Same(activeEntry1, Assert.Single(factory._activeHandlers).Value.Value); + Assert.Equal(1, factory.GetActiveEntryCount()); + var activeEntry2 = factory.GetActiveEntry("github"); + Assert.Same(activeEntry1, activeEntry2); // Act - 3 - Now simulate the timer triggering to complete the expiry. var (completionSource, expiryTask) = factory.ActiveEntryState[activeEntry1]; @@ -323,7 +329,7 @@ public async Task Factory_CreateClient_WithExpiry_HandlerCanBeReusedBeforeExpiry await expiryTask; // Assert - 3 - Assert.Empty(factory._activeHandlers); + Assert.Equal(0, factory.GetActiveEntryCount()); Assert.True(factory.CleanupTimerStarted.IsSet, "Cleanup timer started"); var expiredEntry1 = Assert.Single(factory._expiredHandlers); @@ -333,12 +339,13 @@ public async Task Factory_CreateClient_WithExpiry_HandlerCanBeReusedBeforeExpiry var client3 = factory.CreateClient("github"); // Assert - 4 - var activeEntry2 = Assert.Single(factory._activeHandlers).Value.Value; - Assert.Equal("github", activeEntry1.Name); - Assert.Equal(TimeSpan.FromMinutes(2), activeEntry1.Lifetime); - Assert.NotNull(activeEntry1.Handler); - Assert.NotSame(activeEntry1, activeEntry2); - Assert.NotSame(activeEntry1.Handler, activeEntry2.Handler); + Assert.Equal(1, factory.GetActiveEntryCount()); + var activeEntry3 = factory.GetActiveEntry("github"); + Assert.Equal("github", activeEntry3.Name); + Assert.Equal(TimeSpan.FromMinutes(2), activeEntry3.Lifetime); + Assert.NotNull(activeEntry3.Handler); + Assert.NotSame(activeEntry1, activeEntry3); + Assert.NotSame(activeEntry1.Handler, activeEntry3.Handler); } [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported), nameof(PlatformDetection.IsPreciseGcSupported))] diff --git a/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DependencyInjection/HttpClientFactoryServiceCollectionExtensionsTest.cs b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DependencyInjection/HttpClientFactoryServiceCollectionExtensionsTest.cs index fec6276e6ce26..2ba4658df9139 100644 --- a/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DependencyInjection/HttpClientFactoryServiceCollectionExtensionsTest.cs +++ b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/DependencyInjection/HttpClientFactoryServiceCollectionExtensionsTest.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; diff --git a/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/PrimaryHandlerExposureTest.cs b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/PrimaryHandlerExposureTest.cs new file mode 100644 index 0000000000000..049d305f242bb --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/PrimaryHandlerExposureTest.cs @@ -0,0 +1,101 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Http +{ + public class PrimaryHandlerExposureTest + { + private bool IsPrimaryHandlerExposed(ServiceCollection serviceCollection, string name) + { + var services = serviceCollection.BuildServiceProvider(); + var optionsMonitor = services.GetRequiredService>(); + + var options = optionsMonitor.Get(name); + return options._primaryHandlerExposed; + } + + [Fact] + public void NotExposed() + { + var serviceCollection = new ServiceCollection(); + string name = "test"; + + serviceCollection.AddHttpClient(name) + .AddHttpMessageHandler(() => Mock.Of()); + + // --- + + bool primaryHandlerExposed = IsPrimaryHandlerExposed(serviceCollection, name); + + Assert.False(primaryHandlerExposed); + } + + [Fact] + public void ExposedByConfigurePrimaryHandler() + { + var serviceCollection = new ServiceCollection(); + string name = "test"; + + serviceCollection.AddHttpClient(name) + .AddHttpMessageHandler(() => Mock.Of()) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler()); // exposure + + // --- + + bool primaryHandlerExposed = IsPrimaryHandlerExposed(serviceCollection, name); + + Assert.True(primaryHandlerExposed); + } + + [Fact] + public void ExposedByConfigureBuilder() + { + var serviceCollection = new ServiceCollection(); + string name = "test"; + + serviceCollection.AddHttpClient(name) + .AddHttpMessageHandler(() => Mock.Of()) + .ConfigureHttpMessageHandlerBuilder(builder => { }); // exposure + + // --- + + bool primaryHandlerExposed = IsPrimaryHandlerExposed(serviceCollection, name); + + Assert.True(primaryHandlerExposed); + } + + [Fact] + public void ExposedByBuilderFilter() + { + var serviceCollection = new ServiceCollection(); + string name = "test"; + + serviceCollection.AddHttpClient(name) + .AddHttpMessageHandler(() => Mock.Of()); + + serviceCollection.TryAddEnumerable(ServiceDescriptor.Singleton()); // exposure + + // --- + + bool primaryHandlerExposed = IsPrimaryHandlerExposed(serviceCollection, name); + + Assert.True(primaryHandlerExposed); + } + + private class TestHandlerBuilderFilter : IHttpMessageHandlerBuilderFilter + { + public Action Configure(Action next) + { + return builder => next(builder); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/ScopeTest.cs b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/ScopeTest.cs new file mode 100644 index 0000000000000..f5050776d78f0 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Http/tests/Microsoft.Extensions.Http.Tests/ScopeTest.cs @@ -0,0 +1,849 @@ +// 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.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Http +{ + public class ScopeTest + { + private static readonly TimeSpan HandlerLifetime = TimeSpan.FromSeconds(5); + + private const string NamedClientName = "test"; + private const string TypedClientName = nameof(TypedClient); + + [Fact] + public void MessageHandler_ManualScope() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: false, + registerTypedClient: false, + isExpiryTest: false, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpMessageHandler topHandler, sameScopeTopHandler, otherScopeTopHandler; + LifetimeTrackingHttpMessageHandler handlerFromFactory; + ScopedService scopedServiceFromContainer, scopedServiceFromContainerOtherScope; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + topHandler = factory.CreateHandler(name); + handlerFromFactory = GetHandlerFromFactory(factory, name); + sameScopeTopHandler = factory.CreateHandler(name); + Assert.Equal(2, scopedServiceInstanceCount); // 1 for container scope + 1 for handler lifetime scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainerOtherScope = scopeServices.GetRequiredService(); + Assert.Equal(3, scopedServiceInstanceCount); // 2 for 2 container scopes + 1 for handler lifetime scope + + var factory = scopeServices.GetRequiredService(); + otherScopeTopHandler = factory.CreateHandler(name); + Assert.Equal(3, scopedServiceInstanceCount); // 2 for 2 container scopes + 1 for handler lifetime scope + } + + var scopedServiceFromHandler = ExtractScopedService(topHandler); + var scopedServiceFromOtherScopeHandler = ExtractScopedService(otherScopeTopHandler); + + // --- + + Assert.Same(topHandler, handlerFromFactory); // full handler chain is cached in factory + Assert.Same(topHandler, sameScopeTopHandler); // within lifetime handlers are cached + Assert.Same(sameScopeTopHandler, otherScopeTopHandler); // within lifetime handlers are cached + + Assert.NotSame(scopedServiceFromContainer, scopedServiceFromContainerOtherScope); // DI expected behavior + Assert.NotSame(scopedServiceFromContainer, scopedServiceFromHandler); // handler has custom scope unrelated to outer scope + Assert.NotSame(scopedServiceFromContainerOtherScope, scopedServiceFromOtherScopeHandler); // handler has custom scope unrelated to outer scope + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + public async Task MessageHandler_ManualScope_Expiry() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: false, + registerTypedClient: false, + isExpiryTest: true, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpMessageHandler topHandler, newLifetimeTopHandler; + ScopedService scopedServiceFromContainer; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + topHandler = factory.CreateHandler(name); + Assert.Equal(2, scopedServiceInstanceCount); // 1 for container scope + 1 for handler lifetime scope + + await WaitForExpiry(factory, name); + + newLifetimeTopHandler = factory.CreateHandler(name); + Assert.Equal(3, scopedServiceInstanceCount); // 1 for container scope + 2 for 2 handler lifetime scopes + } + + var primaryHandler = GetPrimaryHandler(topHandler); + var newLifetimePrimaryHandler = GetPrimaryHandler(newLifetimeTopHandler); + + var scopedServiceFromHandler = ExtractScopedService(topHandler); + var scopedServiceFromNewLifetimeHandler = ExtractScopedService(newLifetimeTopHandler); + + // --- + + Assert.NotSame(topHandler, newLifetimeTopHandler); // lifetime expired, so new handler is created + + Assert.NotSame(primaryHandler, newLifetimePrimaryHandler); // in new lifetime, whole chain is created incl. primary handler + + Assert.NotSame(scopedServiceFromHandler, scopedServiceFromNewLifetimeHandler); // custom scope is bound to lifetime + } + + [Fact] + public void MessageHandler_PreserveExistingScope() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: true, + registerTypedClient: false, + isExpiryTest: false, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpMessageHandler topHandler, sameScopeTopHandler, otherScopeTopHandler; + LifetimeTrackingHttpMessageHandler handlerFromFactory; + ScopedService scopedServiceFromContainer, scopedServiceFromContainerOtherScope; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + topHandler = factory.CreateHandler(name); + handlerFromFactory = GetHandlerFromFactory(factory, name); + sameScopeTopHandler = factory.CreateHandler(name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainerOtherScope = scopeServices.GetRequiredService(); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + + var factory = scopeServices.GetRequiredService(); + otherScopeTopHandler = factory.CreateHandler(name); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + } + + var primaryHandler = GetPrimaryHandler(topHandler); + var scopedServiceFromHandler = ExtractScopedService(topHandler); + + var otherScopePrimaryHandler = GetPrimaryHandler(otherScopeTopHandler); + var scopedServiceFromOtherScopeHandler = ExtractScopedService(otherScopeTopHandler); + + // --- + + Assert.Same(topHandler, sameScopeTopHandler); // within the scope handlers are cached + Assert.NotSame(topHandler, otherScopeTopHandler); // in another scope different top handler will be created + + Assert.Same(primaryHandler, handlerFromFactory.InnerHandler); // only primary handler is cached in factory + Assert.Same(primaryHandler, otherScopePrimaryHandler); // lifetime not expired between scopes, so old primary handler is reused + + Assert.NotSame(scopedServiceFromContainer, scopedServiceFromContainerOtherScope); // DI expected behavior + Assert.Same(scopedServiceFromContainer, scopedServiceFromHandler); // outer scope is preserved in handler + Assert.Same(scopedServiceFromContainerOtherScope, scopedServiceFromOtherScopeHandler); // outer scope is preserved in handler + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + public async Task MessageHandler_PreserveExistingScope_Expiry() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: true, + registerTypedClient: false, + isExpiryTest: true, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpMessageHandler topHandler, newLifetimeSameScopeTopHandler, otherScopeTopHandler; + HttpMessageHandler handlerFromFactory, newLifetimeSameScopeHandlerFromFactory, otherScopeHandlerFromFactory; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + topHandler = factory.CreateHandler(name); + handlerFromFactory = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + await WaitForExpiry(factory, name); + + newLifetimeSameScopeTopHandler = factory.CreateHandler(name); + newLifetimeSameScopeHandlerFromFactory = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var factory = scopeServices.GetRequiredService(); + + otherScopeTopHandler = factory.CreateHandler(name); + otherScopeHandlerFromFactory = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + } + + // --- + + Assert.Same(topHandler, newLifetimeSameScopeTopHandler); // within the scope handlers are cached + + Assert.NotNull(handlerFromFactory); // in scope 1 handler is successfully created and cached (both in singleton and scope cache) + Assert.Null(newLifetimeSameScopeHandlerFromFactory); // after expiry singleton cache is empty + Assert.NotNull(otherScopeHandlerFromFactory); // in scope 2 new handler is successfully created and cached + Assert.NotSame(handlerFromFactory, otherScopeHandlerFromFactory); + } + + [Fact] + public void NamedClient_ManualScope() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: false, + registerTypedClient: false, + isExpiryTest: false, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpClient client, sameScopeClient, otherScopeClient; + HttpMessageHandler topHandler, sameScopeTopHandler, otherScopeTopHandler; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + client = factory.CreateClient(name); + topHandler = GetHandlerFromFactory(factory, name); + sameScopeClient = factory.CreateClient(name); + sameScopeTopHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 1 for container scope + 1 for handler lifetime scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainerOtherScope = scopeServices.GetRequiredService(); + Assert.Equal(3, scopedServiceInstanceCount); // 2 for 2 container scopes + 1 for handler lifetime scope + + var factory = scopeServices.GetRequiredService(); + otherScopeClient = factory.CreateClient(name); + otherScopeTopHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(3, scopedServiceInstanceCount); // 2 for 2 container scopes + 1 for handler lifetime scope + } + + // --- + + Assert.NotSame(client, sameScopeClient); // re-created each time + Assert.NotSame(sameScopeClient, otherScopeClient); // re-created each time + + Assert.Same(topHandler, sameScopeTopHandler); // within lifetime handlers are cached + Assert.Same(topHandler, otherScopeTopHandler); // within lifetime handlers are cached + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + public async Task NamedClient_ManualScope_Expiry() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: false, + registerTypedClient: false, + isExpiryTest: true, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpMessageHandler topHandler, newLifetimeSameScopeTopHandler; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + var client = factory.CreateClient(name); + topHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 1 for container scope + 1 for handler lifetime scope + + await WaitForExpiry(factory, name); + + var newLifetimeSameScopeClient = factory.CreateClient(name); + newLifetimeSameScopeTopHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(3, scopedServiceInstanceCount); // 1 for container scope + 2 for 2 handler lifetime scopes + } + + // --- + + Assert.NotSame(topHandler, newLifetimeSameScopeTopHandler); // lifetime expired, so new handler is created + } + + [Fact] + public void NamedClient_PreserveExistingScope() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: true, + registerTypedClient: false, + isExpiryTest: false, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpClient client, sameScopeClient, otherScopeClient; + HttpMessageHandler handler, sameScopeHandler, otherScopeHandler; + ScopedService scopedServiceFromContainer, scopedServiceFromContainerOtherScope; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + client = factory.CreateClient(name); + handler = GetHandlerFromFactory(factory, name); + sameScopeClient = factory.CreateClient(name); + sameScopeHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainerOtherScope = scopeServices.GetRequiredService(); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + + var factory = scopeServices.GetRequiredService(); + otherScopeClient = factory.CreateClient(name); + otherScopeHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + } + + // --- + + Assert.NotSame(client, sameScopeClient); // re-created each time + Assert.NotSame(sameScopeClient, otherScopeClient); // re-created each time + + Assert.Same(handler, sameScopeHandler); // within lifetime primary handlers are cached + Assert.Same(handler, otherScopeHandler); // lifetime not expired between scopes, so old primary handler is reused + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + public async Task NamedClient_PreserveExistingScope_Expiry() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: true, + registerTypedClient: false, + isExpiryTest: true, + () => scopedServiceInstanceCount++ + ); + + var name = NamedClientName; + + // --- + + HttpClient client, newLifetimeSameScopeClient; + HttpMessageHandler handler, newLifetimeSameScopeHandler; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + client = factory.CreateClient(name); + handler = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + await WaitForExpiry(factory, name); + + newLifetimeSameScopeClient = factory.CreateClient(name); + newLifetimeSameScopeHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + } + + // --- + + Assert.NotNull(newLifetimeSameScopeClient); // client is successfully created using scope cached handler + Assert.Null(newLifetimeSameScopeHandler); // lifetime expired, so singleton cache is empty. full chain is cached on scope level + } + + [Fact] + public void TypedClient_ManualScope() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: false, + registerTypedClient: true, + isExpiryTest: false, + () => scopedServiceInstanceCount++ + ); + + var name = TypedClientName; + + // --- + + TypedClient typedClient, sameScopeTypedClient, otherScopeTypedClient; + HttpMessageHandler topHandler, sameScopeTopHandler, otherScopeTopHandler; + ScopedService scopedServiceFromContainer, scopedServiceFromContainerOtherScope; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + typedClient = scopeServices.GetRequiredService(); + topHandler = GetHandlerFromFactory(factory, name); + sameScopeTypedClient = scopeServices.GetRequiredService(); + sameScopeTopHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 1 for container scope + 1 for handler lifetime scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainerOtherScope = scopeServices.GetRequiredService(); + Assert.Equal(3, scopedServiceInstanceCount); // 2 for 2 container scopes + 1 for handler lifetime scope + + var factory = scopeServices.GetRequiredService(); + otherScopeTypedClient = scopeServices.GetRequiredService(); + otherScopeTopHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(3, scopedServiceInstanceCount); // 2 for 2 container scopes + 1 for handler lifetime scope + } + + // --- + + Assert.NotSame(typedClient, sameScopeTypedClient); // transient + Assert.NotSame(sameScopeTypedClient, otherScopeTypedClient); // transient + + Assert.NotSame(typedClient.HttpClient, sameScopeTypedClient.HttpClient); // re-created each time + Assert.NotSame(sameScopeTypedClient.HttpClient, otherScopeTypedClient.HttpClient); // re-created each time + + Assert.Same(scopedServiceFromContainer, typedClient.ScopedService); // typed client instances are created in outer scope (both in scope 1) + Assert.Same(typedClient.ScopedService, sameScopeTypedClient.ScopedService); // typed client instances are created in outer scope (both in scope 1) + Assert.NotSame(typedClient.ScopedService, otherScopeTypedClient.ScopedService); // typed client instances are created in outer scope (scope 1 vs scope 2) + Assert.Same(scopedServiceFromContainerOtherScope, otherScopeTypedClient.ScopedService); // typed client instances are created in outer scope (both in scope 2) + + Assert.Same(topHandler, sameScopeTopHandler); // within lifetime handlers are cached + Assert.Same(sameScopeTopHandler, otherScopeTopHandler); // within lifetime handlers are cached + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + public async Task TypedClient_ManualScope_Expiry() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: false, + registerTypedClient: true, + isExpiryTest: true, + () => scopedServiceInstanceCount++ + ); + + var name = TypedClientName; + + // --- + + TypedClient typedClient, newLifetimeSameScopeTypedClient; + HttpMessageHandler topHandler, newLifetimeSameScopeTopHandler; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + typedClient = scopeServices.GetRequiredService(); + topHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 1 for container scope + 1 for handler lifetime scope + + await WaitForExpiry(factory, name); + + newLifetimeSameScopeTypedClient = scopeServices.GetRequiredService(); + newLifetimeSameScopeTopHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(3, scopedServiceInstanceCount); // 1 for container scope + 2 for 2 handler lifetime scopes + } + + // --- + + Assert.NotSame(typedClient, newLifetimeSameScopeTypedClient); // transient + Assert.NotSame(typedClient.HttpClient, newLifetimeSameScopeTypedClient.HttpClient); // re-created each time + + Assert.Same(typedClient.ScopedService, newLifetimeSameScopeTypedClient.ScopedService); // typed client instances are created in outer scope + + Assert.NotSame(topHandler, newLifetimeSameScopeTopHandler); // lifetime expired, so new handler is created + } + + [Fact] + public void TypedClient_PreserveExistingScope() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: true, + registerTypedClient: true, + isExpiryTest: false, + () => scopedServiceInstanceCount++ + ); + + var name = TypedClientName; + + // --- + + TypedClient typedClient, sameScopeTypedClient, otherScopeTypedClient; + HttpMessageHandler handler, sameScopeHandler, otherScopeHandler; + ScopedService scopedServiceFromContainer, scopedServiceFromContainerOtherScope; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + typedClient = scopeServices.GetRequiredService(); + handler = GetHandlerFromFactory(factory, name); + sameScopeTypedClient = scopeServices.GetRequiredService(); + sameScopeHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + } + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + scopedServiceFromContainerOtherScope = scopeServices.GetRequiredService(); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + + var factory = scopeServices.GetRequiredService(); + otherScopeTypedClient = scopeServices.GetRequiredService(); + otherScopeHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(2, scopedServiceInstanceCount); // 2 for 2 container scopes + } + + // --- + + Assert.NotSame(typedClient, sameScopeTypedClient); // transient + Assert.NotSame(sameScopeTypedClient, otherScopeTypedClient); // transient + + Assert.NotSame(typedClient.HttpClient, sameScopeTypedClient.HttpClient); // re-created each time + Assert.NotSame(sameScopeTypedClient.HttpClient, otherScopeTypedClient.HttpClient); // re-created each time + + Assert.Same(scopedServiceFromContainer, typedClient.ScopedService); // typed client instances are created in outer scope (both in scope 1) + Assert.Same(typedClient.ScopedService, sameScopeTypedClient.ScopedService); // typed client instances are created in outer scope (both in scope 1) + Assert.NotSame(typedClient.ScopedService, otherScopeTypedClient.ScopedService); // typed client instances are created in outer scope (scope 1 vs scope 2) + Assert.Same(scopedServiceFromContainerOtherScope, otherScopeTypedClient.ScopedService); // typed client instances are created in outer scope (both in scope 2) + + Assert.Same(handler, sameScopeHandler); // within lifetime primary handlers are cached + + Assert.Same(handler, otherScopeHandler); // lifetime not expired between scopes, so old primary handler is reused + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsThreadingSupported))] + public async Task TypedClient_PreserveExistingScope_Expiry() + { + int scopedServiceInstanceCount = 0; + + var services = PrepareContainer( + preserveExistingScope: true, + registerTypedClient: true, + isExpiryTest: true, + () => scopedServiceInstanceCount++ + ); + + var name = TypedClientName; + + // --- + + TypedClient typedClient, newLifetimeSameScopeTypedClient; + HttpMessageHandler handler, newLifetimeSameScopeHandler; + + using (var scope = services.CreateScope()) + { + var scopeServices = scope.ServiceProvider; + var scopedServiceFromContainer = scopeServices.GetRequiredService(); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + var factory = scopeServices.GetRequiredService(); + + typedClient = scopeServices.GetRequiredService(); + handler = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + + await WaitForExpiry(factory, name); + + newLifetimeSameScopeTypedClient = scopeServices.GetRequiredService(); + newLifetimeSameScopeHandler = GetHandlerFromFactory(factory, name); + Assert.Equal(1, scopedServiceInstanceCount); // 1 for container scope + } + + // --- + + Assert.Same(typedClient.ScopedService, newLifetimeSameScopeTypedClient.ScopedService); // typed client instances are created in outer scope + + Assert.Null(newLifetimeSameScopeHandler); // lifetime expired, so singleton cache is empty. full handler chain is cached on scope level + } + + private ServiceProvider PrepareContainer(bool preserveExistingScope, bool registerTypedClient, bool isExpiryTest, Action scopedServiceInstanceCountIncrement) + { + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddScoped(sc => + { + scopedServiceInstanceCountIncrement.Invoke(); + return new ScopedService(); + }); + + serviceCollection.AddScoped(); + + if (isExpiryTest) + { + serviceCollection.AddSingleton(); // substitute default factory to enable await expiry + } + + var builder = registerTypedClient + ? serviceCollection.AddHttpClient() + : serviceCollection.AddHttpClient(NamedClientName); + + builder.SetPreserveExistingScope(preserveExistingScope) + .AddHttpMessageHandler(); + + if (isExpiryTest) + { + builder.SetHandlerLifetime(HandlerLifetime); + } + + return serviceCollection.BuildServiceProvider(); + } + + private HttpMessageHandler GetPrimaryHandler(HttpMessageHandler topHandler) + { + var primaryHandler = topHandler; + while (primaryHandler is DelegatingHandler) + { + primaryHandler = ((DelegatingHandler)primaryHandler).InnerHandler; + } + + return primaryHandler; + } + + private ScopedService ExtractScopedService(HttpMessageHandler topHandler) + { + var lifetimeHandler = (LifetimeTrackingHttpMessageHandler)topHandler; + var loggingHandler = (LoggingScopeHttpMessageHandler)lifetimeHandler.InnerHandler; + var scopeHandler = (MessageHandlerWithScopedService)loggingHandler.InnerHandler; + return scopeHandler.ScopedService; + } + + private LifetimeTrackingHttpMessageHandler GetHandlerFromFactory(IHttpMessageHandlerFactory factory, string name) => GetHandlerFromFactory((DefaultHttpClientFactory)factory, name); + private LifetimeTrackingHttpMessageHandler GetHandlerFromFactory(IHttpClientFactory factory, string name) => GetHandlerFromFactory((DefaultHttpClientFactory)factory, name); + + private LifetimeTrackingHttpMessageHandler GetHandlerFromFactory(DefaultHttpClientFactory factory, string name) + { + var entry = factory.GetActiveEntry(name); + Assert.NotNull(entry); + Assert.False(entry.IsPrimary); + return entry.Handler; + } + + private LifetimeTrackingHttpMessageHandler GetHandlerFromFactory(IScopedHttpMessageHandlerFactory factory, string name) => GetHandlerFromFactory((DefaultScopedHttpClientFactory)factory, name); + private LifetimeTrackingHttpMessageHandler GetHandlerFromFactory(IScopedHttpClientFactory factory, string name) => GetHandlerFromFactory((DefaultScopedHttpClientFactory)factory, name); + + private LifetimeTrackingHttpMessageHandler GetHandlerFromFactory(DefaultScopedHttpClientFactory factory, string name) + { + var entry = factory._singletonFactory.GetActiveEntry(name); + if (entry != null) + { + Assert.True(entry.IsPrimary); + return entry.Handler; + } + return null; + } + + private class ScopedService + { + } + + private class MessageHandlerWithScopedService : DelegatingHandler + { + public ScopedService ScopedService { get; private set; } + + public MessageHandlerWithScopedService(ScopedService scopedService) + { + ScopedService = scopedService; + } + } + + private class TypedClient + { + public ScopedService ScopedService { get; private set; } + public HttpClient HttpClient { get; private set; } + + public TypedClient(ScopedService scopedService, HttpClient httpClient) + { + ScopedService = scopedService; + HttpClient = httpClient; + } + } + + private static async Task WaitForExpiry(IHttpMessageHandlerFactory factory, string name) => await WaitForExpiry((TestHttpClientFactory)factory, name); + private static async Task WaitForExpiry(IHttpClientFactory factory, string name) => await WaitForExpiry((TestHttpClientFactory)factory, name); + private static async Task WaitForExpiry(IScopedHttpMessageHandlerFactory factory, string name) + => await WaitForExpiry((TestHttpClientFactory)((DefaultScopedHttpClientFactory)factory)._singletonFactory, name); + private static async Task WaitForExpiry(IScopedHttpClientFactory factory, string name) + => await WaitForExpiry((TestHttpClientFactory)((DefaultScopedHttpClientFactory)factory)._singletonFactory, name); + + private static async Task WaitForExpiry(TestHttpClientFactory factory, string name) => await factory.WaitForExpiry(name); + + // allows awaiting on expiry timers + private class TestHttpClientFactory : DefaultHttpClientFactory + { + public TestHttpClientFactory( + IServiceProvider services, + IServiceScopeFactory scopeFactory, + ILoggerFactory loggerFactory, + IOptionsMonitor optionsMonitor, + IEnumerable filters) + : base(services, scopeFactory, loggerFactory, optionsMonitor, filters) + { + ActiveEntryState = new Dictionary>(); + } + + public Dictionary> ActiveEntryState { get; } + + internal override void StartHandlerEntryTimer(ActiveHandlerTrackingEntry entry) + { + lock (ActiveEntryState) + { + if (!ActiveEntryState.ContainsKey(entry)) + { + ActiveEntryState.Add(entry, new TaskCompletionSource()); + } + + base.StartHandlerEntryTimer(entry); + } + } + + internal override void ExpiryTimer_Tick(object state) + { + lock (ActiveEntryState) + { + base.ExpiryTimer_Tick(state); + + var entry = (ActiveHandlerTrackingEntry)state; + TaskCompletionSource completionSource = ActiveEntryState[entry]; + ActiveEntryState.Remove(entry); + completionSource.SetResult(true); + } + } + + internal async Task WaitForExpiry(string name) + { + Task t; + + lock (ActiveEntryState) + { + var entry = GetActiveEntry(name); + if (entry == null) + { + t = Task.CompletedTask; + } + if (!ActiveEntryState.TryGetValue(entry, out var completionSource)) + { + t = Task.CompletedTask; + } + t = completionSource.Task; + } + + await t; + } + } + } +}