Based on Eben Roux's answer and the comments there, as well as Microsoft's IHttpClientFactory
, we can create a class that combines good caching, very easy default usage, and fairly easy customization.
As it turns out, it is the HttpMessageHandler
(rather than the HttpClient
) that ought to be cached correctly: we should reuse it as much as possible, but for no more than a few minutes (Microsoft's IHttpClientFactory
implementation seems to use 2 minutes), because of potential DNS updates.
First, we allow the client to specify a "purpose", combining a custom HttpMessageHandler
factory with a unique name. Wherever the client wants to use a custom HttpMessageHandler
(such as for client certificates), it represents it in a purpose. Purposes with the same name are considered equal and exchangeable, allowing us to do caching.
public class HttpClientPurpose
{
public override string ToString() => $"{{{this.GetType().Name} {this.UniqueName}}}";
public override bool Equals(object other) => other is HttpClientPurpose typedOther && String.Equals(this.UniqueName, typedOther.UniqueName, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this.UniqueName);
internal static HttpClientPurpose GenericPurpose { get; } = new HttpClientPurpose("InternalGenericPurpose", () => new HttpClientHandler());
public string UniqueName { get; }
public Func<HttpMessageHandler> MessageHandlerFactory { get; }
public HttpClientPurpose(string uniqueName, Func<HttpMessageHandler> messageHandlerFactory)
{
this.UniqueName = uniqueName ?? throw new ArgumentNullException(nameof(uniqueName));
this.MessageHandlerFactory = messageHandlerFactory ?? throw new ArgumentNullException(nameof(messageHandlerFactory));
}
}
The client then uses an implementation of our custom INetHttpClientFactory
(avoiding a naming conflict with the one from Microsoft) to get an instance according to their wishes.
/// <summary>
/// Provides instances of the System.Net.Http.HttpClient.
/// </summary>
public interface INetHttpClientFactory
{
/// <summary>
/// <para>
/// Returns a generic HttpClient, with no exotic options like client certificates or custom server certificate validation.
/// </para>
/// <para>
/// Returns a client that is valid for at least two minutes. Behavior is undefined if it is used beyond that time.
/// </para>
/// </summary>
HttpClient CreateClient();
}
/// <summary>
/// <para>
/// Caches HttpClients or their handlers by purpose, as resource-efficiently as possible, while still allowing fairly easy customization, such as client certificates or server certificate validation.
/// </para>
/// </summary>
public interface IPurposeCachedHttpClientFactory : INetHttpClientFactory
{
HttpClient CreateClient(HttpClientPurpose purpose);
HttpMessageHandler CreateHandler(HttpClientPurpose purpose);
}
public class PurposeCachedHttpClientFactory : IPurposeCachedHttpClientFactory
{
private static IMemoryCache CachedClients { get; } = new MemoryCache(new MemoryCacheOptions());
private static IMemoryCache ExpiredClients { get; } = new MemoryCache(new MemoryCacheOptions());
private static readonly TimeSpan ClientLifetime = TimeSpan.FromSeconds(240); // Match the time TCP connections are kept open, for symmetry if nothing else (must not be less than two minutes for reasonable use)
/// <summary>
/// <para>
/// Returns a generic HttpClient, with no exotic options like client certificates or custom server certificate validation.
/// </para>
/// <para>
/// Returns a cached client that is valid for at least two minutes. Behavior is undefined if it is used beyond that time.
/// </para>
/// </summary>
public HttpClient CreateClient()
{
return this.CreateClient(HttpClientPurpose.GenericPurpose);
}
/// <summary>
/// <para>
/// Returns a customized HttpClient, whose message handler is determined by its purpose.
/// </para>
/// <para>
/// Returns a cached client that is valid for at least two minutes. Behavior is undefined if it is used beyond that time.
/// </para>
/// </summary>
public HttpClient CreateClient(HttpClientPurpose purpose)
{
var messageHandler = this.CreateHandler(purpose);
return new HttpClient(messageHandler, disposeHandler: false); // Essential to NOT dispose the handler when disposing the client
}
/// <summary>
/// <para>
/// Returns a cached HttpMessageHandler determined by the purpose.
/// </para>
/// <para>
/// It is recommended to use the CreateClient() method, unless direct access to the handler is needed.
/// </para>
/// <para>
/// Returns a cached handler that is valid for at least two minutes. Behavior is undefined if it is used beyond that time.
/// </para>
/// </summary>
public HttpMessageHandler CreateHandler(HttpClientPurpose purpose)
{
var messageHandler = this.CreateMessageHandler(purpose.UniqueName, purpose.MessageHandlerFactory);
return messageHandler;
}
private HttpMessageHandler CreateMessageHandler(string uniqueName, Func<HttpMessageHandler> messageHandlerFactory)
{
// Try to use a cached instance
return CachedClients.GetOrCreate(key: uniqueName, factory: cacheEntry =>
{
cacheEntry.AbsoluteExpirationRelativeToNow = ClientLifetime;
cacheEntry.RegisterPostEvictionCallback(DidEvictActiveClient);
return messageHandlerFactory();
});
}
/// <summary>
/// Schedules expired clients to be disposed (via the cache of expired items) after they are evicted from the cache of active clients.
/// </summary>
private static void DidEvictActiveClient(object key, object value, EvictionReason reason, object state)
{
// Schedule it to be disposed
ExpiredClients.GetOrCreate(key: value, factory: cacheEntry =>
{
cacheEntry.Priority = CacheItemPriority.NeverRemove;
// Eventually dispose it
cacheEntry.AbsoluteExpirationRelativeToNow = ClientLifetime;
cacheEntry.RegisterPostEvictionCallback(DidEvictExpiredClient);
System.Diagnostics.Debug.Assert(cacheEntry.Key != null);
return cacheEntry.Key;
});
}
/// <summary>
/// Disposes expired clients after they are evicted from the cache of expired items (i.e. after a delay).
/// </summary>
private static void DidEvictExpiredClient(object key, object value, EvictionReason reason, object state)
{
// TODO: Put this in a try/catch after confirming that it works without exceptions for some time
((HttpMessageHandler)value).Dispose();
}
}
Points of interest:
- If the client needs no customization, they need not concern themselves with the
HttpClientPurpose
at all.
- The
HttpClient
is always new (and may be disposed by the client). It can be customized for their single usage, such as with a custom timeout.
- We keep a handler in the active cache for 4 minutes.
- After a handler expires, we keep it in the 'expired' cache for 4 minutes. After that, we dispose it. If a client was using it for longer, its activities may be interrupted. Use cases that take that long will have to use a different approach. This could be avoided by using a more complex cleanup approach, such as Microsoft has done with their implementation of
IHttpClientFactory
, but that was beyond our scope here.
- As far as I'm aware, Microsoft's implementation requires the use of configuration to get handlers with custom properties. We have avoided that altogether, using the simple
HttpClientPurpose
class. It can be used from wherever suits the client code.
- The documentation states a limit of 2 minutes. The implementation currently uses 4 minutes, but is free to change as long as it satisfies the documentation.