File: DefaultHttpClientFactory.cs
Web Access
Project: src\src\libraries\Microsoft.Extensions.Http\src\Microsoft.Extensions.Http.csproj (Microsoft.Extensions.Http)
// 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.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Microsoft.Extensions.Http
{
    internal class DefaultHttpClientFactory : IHttpClientFactory, IHttpMessageHandlerFactory
    {
        private static readonly TimerCallback _cleanupCallback = (s) => ((DefaultHttpClientFactory)s!).CleanupTimer_Tick();
        private readonly IServiceProvider _services;
        private readonly IServiceScopeFactory _scopeFactory;
        private readonly IOptionsMonitor<HttpClientFactoryOptions> _optionsMonitor;
        private readonly IHttpMessageHandlerBuilderFilter[] _filters;
        private readonly Func<string, Lazy<ActiveHandlerTrackingEntry>> _entryFactory;
        private readonly Lazy<ILogger> _logger;
 
        // Default time of 10s for cleanup seems reasonable.
        // Quick math:
        // 10 distinct named clients * expiry time >= 1s = approximate cleanup queue of 100 items
        //
        // This seems frequent enough. We also rely on GC occurring to actually trigger disposal.
        private readonly TimeSpan DefaultCleanupInterval = TimeSpan.FromSeconds(10);
 
        // We use a new timer for each regular cleanup cycle, protected with a lock. Note that this scheme
        // doesn't give us anything to dispose, as the timer is started/stopped as needed.
        //
        // There's no need for the factory itself to be disposable. If you stop using it, eventually everything will
        // get reclaimed.
        private Timer? _cleanupTimer;
        private readonly object _cleanupTimerLock;
        private readonly object _cleanupActiveLock;
 
        // Collection of 'active' handlers.
        //
        // Using lazy for synchronization to ensure that only one instance of HttpMessageHandler is created
        // for each name.
        //
        // internal for tests
        internal readonly ConcurrentDictionary<string, Lazy<ActiveHandlerTrackingEntry>> _activeHandlers;
 
        // Collection of 'expired' but not yet disposed handlers.
        //
        // Used when we're rotating handlers so that we can dispose HttpMessageHandler instances once they
        // are eligible for garbage collection.
        //
        // internal for tests
        internal readonly ConcurrentQueue<ExpiredHandlerTrackingEntry> _expiredHandlers;
        private readonly TimerCallback _expiryCallback;
 
        public DefaultHttpClientFactory(
            IServiceProvider services,
            IServiceScopeFactory scopeFactory,
            IOptionsMonitor<HttpClientFactoryOptions> optionsMonitor,
            IEnumerable<IHttpMessageHandlerBuilderFilter> filters)
        {
            ThrowHelper.ThrowIfNull(services);
            ThrowHelper.ThrowIfNull(scopeFactory);
            ThrowHelper.ThrowIfNull(optionsMonitor);
            ThrowHelper.ThrowIfNull(filters);
 
            _services = services;
            _scopeFactory = scopeFactory;
            _optionsMonitor = optionsMonitor;
            _filters = filters.ToArray();
 
            // case-sensitive because named options is.
            _activeHandlers = new ConcurrentDictionary<string, Lazy<ActiveHandlerTrackingEntry>>(StringComparer.Ordinal);
            _entryFactory = (name) =>
            {
                return new Lazy<ActiveHandlerTrackingEntry>(() =>
                {
                    return CreateHandlerEntry(name);
                }, LazyThreadSafetyMode.ExecutionAndPublication);
            };
 
            _expiredHandlers = new ConcurrentQueue<ExpiredHandlerTrackingEntry>();
            _expiryCallback = ExpiryTimer_Tick;
 
            _cleanupTimerLock = new object();
            _cleanupActiveLock = new object();
 
            // We want to prevent a circular dependency between ILoggerFactory and IHttpClientFactory, in case
            // any of ILoggerProvider instances use IHttpClientFactory to send logs to an external server.
            // Logger will be created during the first ExpiryTimer_Tick execution. Lazy guarantees thread safety
            // to prevent creation of unnecessary ILogger objects in case several handlers expired at the same time.
            _logger = new Lazy<ILogger>(
                () => _services.GetRequiredService<ILoggerFactory>().CreateLogger<DefaultHttpClientFactory>(),
                LazyThreadSafetyMode.ExecutionAndPublication);
        }
 
        public HttpClient CreateClient(string name)
        {
            ThrowHelper.ThrowIfNull(name);
 
            HttpMessageHandler handler = CreateHandler(name);
            var client = new HttpClient(handler, disposeHandler: false);
 
            HttpClientFactoryOptions options = _optionsMonitor.Get(name);
            for (int i = 0; i < options.HttpClientActions.Count; i++)
            {
                options.HttpClientActions[i](client);
            }
 
            return client;
        }
 
        public HttpMessageHandler CreateHandler(string name)
        {
            ThrowHelper.ThrowIfNull(name);
 
            ActiveHandlerTrackingEntry entry = _activeHandlers.GetOrAdd(name, _entryFactory).Value;
 
            StartHandlerEntryTimer(entry);
 
            return entry.Handler;
        }
 
        // Internal for tests
        internal ActiveHandlerTrackingEntry CreateHandlerEntry(string name)
        {
            IServiceProvider services = _services;
            var scope = (IServiceScope?)null;
 
            HttpClientFactoryOptions options = _optionsMonitor.Get(name);
            if (!options.SuppressHandlerScope)
            {
                scope = _scopeFactory.CreateScope();
                services = scope.ServiceProvider;
            }
 
            try
            {
                HttpMessageHandlerBuilder builder = services.GetRequiredService<HttpMessageHandlerBuilder>();
                builder.Name = name;
 
                // This is similar to the initialization pattern in:
                // https://github.com/aspnet/Hosting/blob/e892ed8bbdcd25a0dafc1850033398dc57f65fe1/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs#L188
                Action<HttpMessageHandlerBuilder> configure = Configure;
                for (int i = _filters.Length - 1; i >= 0; i--)
                {
                    configure = _filters[i].Configure(configure);
                }
 
                configure(builder);
 
                // Wrap the handler so we can ensure the inner handler outlives the outer handler.
                var handler = new LifetimeTrackingHttpMessageHandler(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);
 
                void Configure(HttpMessageHandlerBuilder b)
                {
                    for (int i = 0; i < options.HttpMessageHandlerBuilderActions.Count; i++)
                    {
                        options.HttpMessageHandlerBuilderActions[i](b);
                    }
 
                    // Logging is added separately in the end. But for now it should be still possible to override it via filters...
                    foreach (Action<HttpMessageHandlerBuilder> action in options.LoggingBuilderActions)
                    {
                        action(b);
                    }
                }
            }
            catch
            {
                // If something fails while creating the handler, dispose the services.
                scope?.Dispose();
                throw;
            }
        }
 
        // Internal for tests
        internal 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<ActiveHandlerTrackingEntry>? 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");
 
            // 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
            // there are still any other outstanding references (we know there is at least one).
            //
            // We use a different state object to track expired handlers. This allows any other thread that acquired
            // the 'active' entry to use it without safety problems.
            var expired = new ExpiredHandlerTrackingEntry(active);
            _expiredHandlers.Enqueue(expired);
 
            Log.HandlerExpired(_logger, active.Name, active.Lifetime);
 
            StartCleanupTimer();
        }
 
        // Internal so it can be overridden in tests
        internal virtual void StartHandlerEntryTimer(ActiveHandlerTrackingEntry entry)
        {
            entry.StartExpiryTimer(_expiryCallback);
        }
 
        // Internal so it can be overridden in tests
        internal virtual void StartCleanupTimer()
        {
            lock (_cleanupTimerLock)
            {
                _cleanupTimer ??= NonCapturingTimer.Create(_cleanupCallback, this, DefaultCleanupInterval, Timeout.InfiniteTimeSpan);
            }
        }
 
        // Internal so it can be overridden in tests
        internal virtual void StopCleanupTimer()
        {
            lock (_cleanupTimerLock)
            {
                _cleanupTimer!.Dispose();
                _cleanupTimer = null;
            }
        }
 
        // Internal for tests
        internal void CleanupTimer_Tick()
        {
            // Stop any pending timers, we'll restart the timer if there's anything left to process after cleanup.
            //
            // With the scheme we're using it's possible we could end up with some redundant cleanup operations.
            // This is expected and fine.
            //
            // An alternative would be to take a lock during the whole cleanup process. This isn't ideal because it
            // would result in threads executing ExpiryTimer_Tick as they would need to block on cleanup to figure out
            // whether we need to start the timer.
            StopCleanupTimer();
 
            if (!Monitor.TryEnter(_cleanupActiveLock))
            {
                // We don't want to run a concurrent cleanup cycle. This can happen if the cleanup cycle takes
                // a long time for some reason. Since we're running user code inside Dispose, it's definitely
                // possible.
                //
                // If we end up in that position, just make sure the timer gets started again. It should be cheap
                // to run a 'no-op' cleanup.
                StartCleanupTimer();
                return;
            }
 
            try
            {
                int initialCount = _expiredHandlers.Count;
                Log.CleanupCycleStart(_logger, initialCount);
 
                var stopwatch = ValueStopwatch.StartNew();
 
                int disposedCount = 0;
                for (int i = 0; i < initialCount; i++)
                {
                    // Since we're the only one removing from _expired, TryDequeue must always succeed.
                    _expiredHandlers.TryDequeue(out ExpiredHandlerTrackingEntry? entry);
                    Debug.Assert(entry != null, "Entry was null, we should always get an entry back from TryDequeue");
 
                    if (entry.CanDispose)
                    {
                        try
                        {
                            entry.InnerHandler.Dispose();
                            entry.Scope?.Dispose();
                            disposedCount++;
                        }
                        catch (Exception ex)
                        {
                            Log.CleanupItemFailed(_logger, entry.Name, ex);
                        }
                    }
                    else
                    {
                        // If the entry is still live, put it back in the queue so we can process it
                        // during the next cleanup cycle.
                        _expiredHandlers.Enqueue(entry);
                    }
                }
 
                Log.CleanupCycleEnd(_logger, stopwatch.GetElapsedTime(), disposedCount, _expiredHandlers.Count);
            }
            finally
            {
                Monitor.Exit(_cleanupActiveLock);
            }
 
            // We didn't totally empty the cleanup queue, try again later.
            if (!_expiredHandlers.IsEmpty)
            {
                StartCleanupTimer();
            }
        }
 
        private static class Log
        {
            public static class EventIds
            {
                public static readonly EventId CleanupCycleStart = new EventId(100, "CleanupCycleStart");
                public static readonly EventId CleanupCycleEnd = new EventId(101, "CleanupCycleEnd");
                public static readonly EventId CleanupItemFailed = new EventId(102, "CleanupItemFailed");
                public static readonly EventId HandlerExpired = new EventId(103, "HandlerExpired");
            }
 
            private static readonly Action<ILogger, int, Exception?> _cleanupCycleStart = LoggerMessage.Define<int>(
                LogLevel.Debug,
                EventIds.CleanupCycleStart,
                "Starting HttpMessageHandler cleanup cycle with {InitialCount} items");
 
            private static readonly Action<ILogger, double, int, int, Exception?> _cleanupCycleEnd = LoggerMessage.Define<double, int, int>(
                LogLevel.Debug,
                EventIds.CleanupCycleEnd,
                "Ending HttpMessageHandler cleanup cycle after {ElapsedMilliseconds}ms - processed: {DisposedCount} items - remaining: {RemainingItems} items");
 
            private static readonly Action<ILogger, string, Exception> _cleanupItemFailed = LoggerMessage.Define<string>(
                LogLevel.Error,
                EventIds.CleanupItemFailed,
                "HttpMessageHandler.Dispose() threw an unhandled exception for client: '{ClientName}'");
 
            private static readonly Action<ILogger, double, string, Exception?> _handlerExpired = LoggerMessage.Define<double, string>(
                LogLevel.Debug,
                EventIds.HandlerExpired,
                "HttpMessageHandler expired after {HandlerLifetime}ms for client '{ClientName}'");
 
 
            public static void CleanupCycleStart(Lazy<ILogger> loggerLazy, int initialCount)
            {
                if (TryGetLogger(loggerLazy, out ILogger? logger))
                {
                    _cleanupCycleStart(logger, initialCount, null);
                }
            }
 
            public static void CleanupCycleEnd(Lazy<ILogger> loggerLazy, TimeSpan duration, int disposedCount, int finalCount)
            {
                if (TryGetLogger(loggerLazy, out ILogger? logger))
                {
                    _cleanupCycleEnd(logger, duration.TotalMilliseconds, disposedCount, finalCount, null);
                }
            }
 
            public static void CleanupItemFailed(Lazy<ILogger> loggerLazy, string clientName, Exception exception)
            {
                if (TryGetLogger(loggerLazy, out ILogger? logger))
                {
                    _cleanupItemFailed(logger, clientName, exception);
                }
            }
 
            public static void HandlerExpired(Lazy<ILogger> loggerLazy, string clientName, TimeSpan lifetime)
            {
                if (TryGetLogger(loggerLazy, out ILogger? logger))
                {
                    _handlerExpired(logger, lifetime.TotalMilliseconds, clientName, null);
                }
            }
 
            private static bool TryGetLogger(Lazy<ILogger> loggerLazy, [NotNullWhen(true)] out ILogger? logger)
            {
                logger = null;
                try
                {
                    logger = loggerLazy.Value;
                }
                catch { } // not throwing in logs
 
                return logger is not null;
            }
        }
    }
}