/
DefaultHttpClientFactory.cs
357 lines (303 loc) · 15.3 KB
/
DefaultHttpClientFactory.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
// 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.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 ILogger _logger;
private readonly IServiceProvider _services;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<HttpClientFactoryOptions> _optionsMonitor;
private readonly IHttpMessageHandlerBuilderFilter[] _filters;
private readonly Func<string, Lazy<ActiveHandlerTrackingEntry>> _entryFactory;
// 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,
ILoggerFactory loggerFactory,
IOptionsMonitor<HttpClientFactoryOptions> optionsMonitor,
IEnumerable<IHttpMessageHandlerBuilderFilter> filters)
{
ThrowHelper.ThrowIfNull(services);
ThrowHelper.ThrowIfNull(scopeFactory);
ThrowHelper.ThrowIfNull(loggerFactory);
ThrowHelper.ThrowIfNull(optionsMonitor);
ThrowHelper.ThrowIfNull(filters);
_services = services;
_scopeFactory = scopeFactory;
_optionsMonitor = optionsMonitor;
_filters = filters.ToArray();
_logger = loggerFactory.CreateLogger<DefaultHttpClientFactory>();
// 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();
}
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);
}
}
}
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(ILogger logger, int initialCount)
{
_cleanupCycleStart(logger, initialCount, null);
}
public static void CleanupCycleEnd(ILogger logger, TimeSpan duration, int disposedCount, int finalCount)
{
_cleanupCycleEnd(logger, duration.TotalMilliseconds, disposedCount, finalCount, null);
}
public static void CleanupItemFailed(ILogger logger, string clientName, Exception exception)
{
_cleanupItemFailed(logger, clientName, exception);
}
public static void HandlerExpired(ILogger logger, string clientName, TimeSpan lifetime)
{
_handlerExpired(logger, lifetime.TotalMilliseconds, clientName, null);
}
}
}
}