Skip to content

Commit aa39c34

Browse files
committed
Better avoidance of closure, fragmentation of cache reduction
1 parent 5481774 commit aa39c34

File tree

5 files changed

+184
-103
lines changed

5 files changed

+184
-103
lines changed

src/Abstract/IHttpClientCache.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ public interface IHttpClientCache : IAsyncDisposable, IDisposable
7878
[Pure]
7979
HttpClient GetSync(string id, Func<HttpClientOptions?> optionsFactory, CancellationToken cancellationToken = default);
8080

81+
/// <summary>
82+
/// Retrieves a configured and ready-to-use synchronous instance of <see cref="HttpClient"/> associated with the
83+
/// specified identifier.
84+
/// </summary>
85+
/// <remarks>This method blocks until the client is fully configured and available. If the options factory
86+
/// performs asynchronous operations, they will be completed before the client is returned. The returned <see
87+
/// cref="HttpClient"/> is intended for reuse and should not be disposed by the caller.</remarks>
88+
/// <param name="id">The unique identifier for the <see cref="HttpClient"/> instance to retrieve. Cannot be null or empty.</param>
89+
/// <param name="optionsFactory">A factory delegate that asynchronously provides <see cref="HttpClientOptions"/> for configuring the client. The
90+
/// returned options may be null to use default settings.</param>
91+
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation before completion. Optional.</param>
92+
/// <returns>An <see cref="HttpClient"/> instance configured according to the provided options. The same instance may be
93+
/// returned for repeated calls with the same identifier.</returns>
94+
[Pure]
95+
HttpClient GetSync(string id, Func<ValueTask<HttpClientOptions?>> optionsFactory, CancellationToken cancellationToken = default);
96+
8197
/// <summary>
8298
/// Should be used if the component using <see cref="IHttpClientCache"/> is disposed (unless the entire app is being disposed). Includes disposal of the <see cref="HttpClient"/>.
8399
/// </summary>

src/HandlerKey.cs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
namespace Soenneker.Utils.HttpClientCache;
1+
using System.Net;
2+
using System.Net.Http;
3+
using System.Net.Security;
4+
5+
namespace Soenneker.Utils.HttpClientCache;
26

37
internal readonly record struct HandlerKey(
4-
double LifetimeSeconds,
5-
int MaxConnections,
8+
long PooledConnectionLifetimeTicks,
9+
int MaxConnectionsPerServer,
610
bool UseCookies,
7-
double ConnectTimeoutSeconds,
8-
double? ResponseDrainTimeoutSeconds,
11+
long ConnectTimeoutTicks,
12+
long? ResponseDrainTimeoutTicks,
913
bool? AllowAutoRedirect,
10-
int? AutomaticDecompression,
11-
double? KeepAlivePingDelaySeconds,
12-
double? KeepAlivePingTimeoutSeconds,
13-
int? KeepAlivePingPolicy,
14+
DecompressionMethods? AutomaticDecompression,
15+
long? KeepAlivePingDelayTicks,
16+
long? KeepAlivePingTimeoutTicks,
17+
HttpKeepAlivePingPolicy? KeepAlivePingPolicy,
1418
bool? UseProxy,
15-
int? ProxyHashCode,
19+
IWebProxy? Proxy,
1620
int? MaxResponseDrainSize,
1721
int? MaxResponseHeadersLength,
18-
int? SslOptionsHashCode);
22+
SslClientAuthenticationOptions? SslOptions);

src/HttpClientCache.cs

Lines changed: 100 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Collections.Generic;
1010
using System.Net;
1111
using System.Net.Http;
12+
using System.Net.Security;
1213
using System.Runtime.CompilerServices;
1314
using System.Threading;
1415
using System.Threading.Tasks;
@@ -18,61 +19,72 @@ namespace Soenneker.Utils.HttpClientCache;
1819
///<inheritdoc cref="IHttpClientCache"/>
1920
public sealed class HttpClientCache : IHttpClientCache
2021
{
21-
private readonly SingletonDictionary<HttpClient, Func<CancellationToken, ValueTask<HttpClientOptions?>>> _httpClients;
22+
private readonly SingletonDictionary<HttpClient, OptionsFactory> _httpClients;
2223
private readonly ConcurrentDictionary<HandlerKey, SocketsHttpHandler> _handlers = new();
2324

24-
private static readonly Func<CancellationToken, ValueTask<HttpClientOptions?>> _nullOptionsFactory = static _ => default;
25+
private static readonly bool _isBrowser = RuntimeUtil.IsBrowser();
26+
27+
private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(100);
28+
private static readonly TimeSpan _defaultConnectTimeout = TimeSpan.FromSeconds(100);
29+
private static readonly TimeSpan _defaultPooledLifetime = TimeSpan.FromMinutes(10);
2530

2631
public HttpClientCache()
2732
{
28-
// We need the token-aware init path so we can call optionsFactory(token).
29-
_httpClients =
30-
new SingletonDictionary<HttpClient, Func<CancellationToken, ValueTask<HttpClientOptions?>>>(async (_, cancellationToken, optionsFactory) =>
31-
{
32-
HttpClientOptions? options = await optionsFactory(cancellationToken)
33-
.NoSync();
33+
// Use method group to avoid a closure and still access instance state.
34+
_httpClients = new SingletonDictionary<HttpClient, OptionsFactory>(InitializeHttpClient);
35+
}
36+
37+
private async ValueTask<HttpClient> InitializeHttpClient(string _, CancellationToken cancellationToken, OptionsFactory factory)
38+
{
39+
HttpClientOptions? options = await factory.Invoke(cancellationToken)
40+
.NoSync();
3441

35-
HttpClient httpClient = CreateHttpClient(options);
42+
HttpClient httpClient = CreateHttpClient(options);
3643

37-
await ConfigureHttpClient(httpClient, options)
38-
.NoSync();
44+
await ConfigureHttpClient(httpClient, options)
45+
.NoSync();
3946

40-
return httpClient;
41-
});
47+
return httpClient;
4248
}
4349

4450
[MethodImpl(MethodImplOptions.AggressiveInlining)]
4551
public ValueTask<HttpClient> Get(string id, CancellationToken cancellationToken = default) =>
46-
_httpClients.Get(id, () => _nullOptionsFactory, cancellationToken);
52+
_httpClients.Get(id, OptionsFactory.Null, cancellationToken);
4753

4854
[MethodImpl(MethodImplOptions.AggressiveInlining)]
4955
public ValueTask<HttpClient> Get(string id, Func<CancellationToken, ValueTask<HttpClientOptions?>> optionsFactory,
50-
CancellationToken cancellationToken = default) => _httpClients.Get(id, () => optionsFactory, cancellationToken);
56+
CancellationToken cancellationToken = default) =>
57+
_httpClients.Get(id, OptionsFactory.From(optionsFactory), cancellationToken);
5158

5259
[MethodImpl(MethodImplOptions.AggressiveInlining)]
5360
public ValueTask<HttpClient> Get(string id, Func<HttpClientOptions?> optionsFactory, CancellationToken cancellationToken = default) =>
54-
_httpClients.Get(id, () => _ => new ValueTask<HttpClientOptions?>(optionsFactory()), cancellationToken);
61+
_httpClients.Get(id, OptionsFactory.From(optionsFactory), cancellationToken);
5562

5663
[MethodImpl(MethodImplOptions.AggressiveInlining)]
5764
public ValueTask<HttpClient> Get(string id, Func<ValueTask<HttpClientOptions?>> optionsFactory, CancellationToken cancellationToken = default) =>
58-
_httpClients.Get(id, () => _ => optionsFactory(), cancellationToken);
65+
_httpClients.Get(id, OptionsFactory.From(optionsFactory), cancellationToken);
5966

6067
[MethodImpl(MethodImplOptions.AggressiveInlining)]
6168
public HttpClient GetSync(string id, CancellationToken cancellationToken = default) =>
62-
_httpClients.GetSync(id, () => _nullOptionsFactory, cancellationToken);
69+
_httpClients.GetSync(id, OptionsFactory.Null, cancellationToken);
6370

6471
[MethodImpl(MethodImplOptions.AggressiveInlining)]
65-
public HttpClient GetSync(
66-
string id, Func<CancellationToken, ValueTask<HttpClientOptions?>> optionsFactory, CancellationToken cancellationToken = default) =>
67-
_httpClients.GetSync(id, () => optionsFactory, cancellationToken);
72+
public HttpClient GetSync(string id, Func<CancellationToken, ValueTask<HttpClientOptions?>> optionsFactory,
73+
CancellationToken cancellationToken = default) =>
74+
_httpClients.GetSync(id, OptionsFactory.From(optionsFactory), cancellationToken);
6875

6976
[MethodImpl(MethodImplOptions.AggressiveInlining)]
7077
public HttpClient GetSync(string id, Func<HttpClientOptions?> optionsFactory, CancellationToken cancellationToken = default) =>
71-
_httpClients.GetSync(id, () => _ => new ValueTask<HttpClientOptions?>(optionsFactory()), cancellationToken);
78+
_httpClients.GetSync(id, OptionsFactory.From(optionsFactory), cancellationToken);
7279

80+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
81+
public HttpClient GetSync(string id, Func<ValueTask<HttpClientOptions?>> optionsFactory, CancellationToken cancellationToken = default) =>
82+
_httpClients.GetSync(id, OptionsFactory.From(optionsFactory), cancellationToken);
83+
84+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
7385
private HttpClient CreateHttpClient(HttpClientOptions? options)
7486
{
75-
if (RuntimeUtil.IsBrowser())
87+
if (_isBrowser)
7688
{
7789
return options?.HttpClientHandler != null ? new HttpClient(options.HttpClientHandler, disposeHandler: false) : new HttpClient();
7890
}
@@ -82,96 +94,92 @@ private HttpClient CreateHttpClient(HttpClientOptions? options)
8294
: new HttpClient(GetOrCreateHandler(options), disposeHandler: false);
8395
}
8496

97+
// Remove async state machine when ModifyClient is null.
8598
[MethodImpl(MethodImplOptions.AggressiveInlining)]
86-
private static async ValueTask ConfigureHttpClient(HttpClient httpClient, HttpClientOptions? options)
99+
private static ValueTask ConfigureHttpClient(HttpClient httpClient, HttpClientOptions? options)
87100
{
88-
httpClient.Timeout = options?.Timeout ?? TimeSpan.FromSeconds(100);
101+
httpClient.Timeout = options?.Timeout ?? _defaultTimeout;
89102

90-
if (options?.BaseAddress != null)
91-
{
92-
Uri baseUri = new(options.BaseAddress);
103+
// Prefer Uri to avoid parsing/allocation.
104+
Uri? baseUri = options?.BaseAddressUri;
93105

94-
if (!Equals(httpClient.BaseAddress, baseUri))
95-
httpClient.BaseAddress = baseUri;
96-
}
106+
if (baseUri is not null && !Equals(httpClient.BaseAddress, baseUri))
107+
httpClient.BaseAddress = baseUri;
97108

98109
AddDefaultRequestHeaders(httpClient, options?.DefaultRequestHeaders);
99110

100111
Func<HttpClient, ValueTask>? modifyClient = options?.ModifyClient;
101-
102-
if (modifyClient is not null)
103-
await modifyClient(httpClient)
104-
.NoSync();
112+
return modifyClient?.Invoke(httpClient) ?? default;
105113
}
106114

107115
private SocketsHttpHandler GetOrCreateHandler(HttpClientOptions? options)
108116
{
109-
double connectTimeoutSeconds = options?.Timeout?.TotalSeconds ?? 100;
110-
111-
var key = new HandlerKey(
112-
LifetimeSeconds: options?.PooledConnectionLifetime?.TotalSeconds ?? 600,
113-
MaxConnections: options?.MaxConnectionsPerServer ?? 40,
114-
UseCookies: options?.UseCookieContainer == true,
115-
ConnectTimeoutSeconds: connectTimeoutSeconds,
116-
ResponseDrainTimeoutSeconds: options?.ResponseDrainTimeout?.TotalSeconds,
117-
AllowAutoRedirect: options?.AllowAutoRedirect,
118-
AutomaticDecompression: (int?)options?.AutomaticDecompression,
119-
KeepAlivePingDelaySeconds: options?.KeepAlivePingDelay?.TotalSeconds,
120-
KeepAlivePingTimeoutSeconds: options?.KeepAlivePingTimeout?.TotalSeconds,
121-
KeepAlivePingPolicy: (int?)options?.KeepAlivePingPolicy,
122-
UseProxy: options?.UseProxy,
123-
ProxyHashCode: options?.Proxy?.GetHashCode(),
124-
MaxResponseDrainSize: options?.MaxResponseDrainSize,
125-
MaxResponseHeadersLength: options?.MaxResponseHeadersLength,
126-
SslOptionsHashCode: options?.SslOptions?.GetHashCode());
127-
128-
return _handlers.GetOrAdd(key, _ =>
117+
// Do NOT tie connect timeout to request timeout.
118+
TimeSpan connectTimeout = options?.ConnectTimeout ?? _defaultConnectTimeout;
119+
120+
// Extract refs so they become part of the key (no closure, no hash-key risk)
121+
IWebProxy? proxy = options?.Proxy;
122+
SslClientAuthenticationOptions? sslOptions = options?.SslOptions;
123+
124+
var key = new HandlerKey(PooledConnectionLifetimeTicks: (options?.PooledConnectionLifetime ?? _defaultPooledLifetime).Ticks,
125+
MaxConnectionsPerServer: options?.MaxConnectionsPerServer ?? 40, UseCookies: options?.UseCookieContainer == true,
126+
ConnectTimeoutTicks: connectTimeout.Ticks, ResponseDrainTimeoutTicks: options?.ResponseDrainTimeout?.Ticks,
127+
AllowAutoRedirect: options?.AllowAutoRedirect, AutomaticDecompression: options?.AutomaticDecompression,
128+
KeepAlivePingDelayTicks: options?.KeepAlivePingDelay?.Ticks, KeepAlivePingTimeoutTicks: options?.KeepAlivePingTimeout?.Ticks,
129+
KeepAlivePingPolicy: options?.KeepAlivePingPolicy, UseProxy: options?.UseProxy, Proxy: proxy, MaxResponseDrainSize: options?.MaxResponseDrainSize,
130+
MaxResponseHeadersLength: options?.MaxResponseHeadersLength, SslOptions: sslOptions);
131+
132+
// static factory => no closure allocation
133+
return _handlers.GetOrAdd(key, static k => CreateHandlerFromKey(k));
134+
}
135+
136+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
137+
private static SocketsHttpHandler CreateHandlerFromKey(in HandlerKey key)
138+
{
139+
var handler = new SocketsHttpHandler
129140
{
130-
var handler = new SocketsHttpHandler
131-
{
132-
PooledConnectionLifetime = TimeSpan.FromSeconds(key.LifetimeSeconds),
133-
MaxConnectionsPerServer = key.MaxConnections,
134-
ConnectTimeout = TimeSpan.FromSeconds(key.ConnectTimeoutSeconds)
135-
};
141+
PooledConnectionLifetime = TimeSpan.FromTicks(key.PooledConnectionLifetimeTicks),
142+
MaxConnectionsPerServer = key.MaxConnectionsPerServer,
143+
ConnectTimeout = TimeSpan.FromTicks(key.ConnectTimeoutTicks)
144+
};
136145

137-
if (key.UseCookies)
138-
handler.CookieContainer = new CookieContainer();
146+
if (key.UseCookies)
147+
handler.CookieContainer = new CookieContainer();
139148

140-
if (key.ResponseDrainTimeoutSeconds.HasValue)
141-
handler.ResponseDrainTimeout = TimeSpan.FromSeconds(key.ResponseDrainTimeoutSeconds.Value);
149+
if (key.ResponseDrainTimeoutTicks.HasValue)
150+
handler.ResponseDrainTimeout = TimeSpan.FromTicks(key.ResponseDrainTimeoutTicks.Value);
142151

143-
if (key.AllowAutoRedirect.HasValue)
144-
handler.AllowAutoRedirect = key.AllowAutoRedirect.Value;
152+
if (key.AllowAutoRedirect.HasValue)
153+
handler.AllowAutoRedirect = key.AllowAutoRedirect.Value;
145154

146-
if (key.AutomaticDecompression.HasValue)
147-
handler.AutomaticDecompression = (DecompressionMethods)key.AutomaticDecompression.Value;
155+
if (key.AutomaticDecompression.HasValue)
156+
handler.AutomaticDecompression = key.AutomaticDecompression.Value;
148157

149-
if (key.KeepAlivePingDelaySeconds.HasValue)
150-
handler.KeepAlivePingDelay = TimeSpan.FromSeconds(key.KeepAlivePingDelaySeconds.Value);
158+
if (key.KeepAlivePingDelayTicks.HasValue)
159+
handler.KeepAlivePingDelay = TimeSpan.FromTicks(key.KeepAlivePingDelayTicks.Value);
151160

152-
if (key.KeepAlivePingTimeoutSeconds.HasValue)
153-
handler.KeepAlivePingTimeout = TimeSpan.FromSeconds(key.KeepAlivePingTimeoutSeconds.Value);
161+
if (key.KeepAlivePingTimeoutTicks.HasValue)
162+
handler.KeepAlivePingTimeout = TimeSpan.FromTicks(key.KeepAlivePingTimeoutTicks.Value);
154163

155-
if (key.KeepAlivePingPolicy.HasValue)
156-
handler.KeepAlivePingPolicy = (HttpKeepAlivePingPolicy)key.KeepAlivePingPolicy.Value;
164+
if (key.KeepAlivePingPolicy.HasValue)
165+
handler.KeepAlivePingPolicy = key.KeepAlivePingPolicy.Value;
157166

158-
if (key.UseProxy.HasValue)
159-
handler.UseProxy = key.UseProxy.Value;
167+
if (key.UseProxy.HasValue)
168+
handler.UseProxy = key.UseProxy.Value;
160169

161-
if (options?.Proxy != null)
162-
handler.Proxy = options.Proxy;
170+
if (key.Proxy is not null)
171+
handler.Proxy = key.Proxy;
163172

164-
if (key.MaxResponseDrainSize.HasValue)
165-
handler.MaxResponseDrainSize = key.MaxResponseDrainSize.Value;
173+
if (key.MaxResponseDrainSize.HasValue)
174+
handler.MaxResponseDrainSize = key.MaxResponseDrainSize.Value;
166175

167-
if (key.MaxResponseHeadersLength.HasValue)
168-
handler.MaxResponseHeadersLength = key.MaxResponseHeadersLength.Value;
176+
if (key.MaxResponseHeadersLength.HasValue)
177+
handler.MaxResponseHeadersLength = key.MaxResponseHeadersLength.Value;
169178

170-
if (options?.SslOptions != null)
171-
handler.SslOptions = options.SslOptions;
179+
if (key.SslOptions is not null)
180+
handler.SslOptions = key.SslOptions;
172181

173-
return handler;
174-
});
182+
return handler;
175183
}
176184

177185
private static void AddDefaultRequestHeaders(HttpClient httpClient, Dictionary<string, string>? headers)
@@ -183,13 +191,15 @@ private static void AddDefaultRequestHeaders(HttpClient httpClient, Dictionary<s
183191
httpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
184192
}
185193

186-
public ValueTask Remove(string id, CancellationToken cancellationToken = default) => _httpClients.Remove(id, cancellationToken);
194+
public ValueTask Remove(string id, CancellationToken cancellationToken = default) =>
195+
_httpClients.Remove(id, cancellationToken);
187196

188-
public void RemoveSync(string id, CancellationToken cancellationToken = default) => _httpClients.RemoveSync(id, cancellationToken);
197+
public void RemoveSync(string id, CancellationToken cancellationToken = default) =>
198+
_httpClients.RemoveSync(id, cancellationToken);
189199

190200
private void DisposeHandlers()
191201
{
192-
foreach (KeyValuePair<HandlerKey, SocketsHttpHandler> kvp in _handlers.ToArray())
202+
foreach (KeyValuePair<HandlerKey, SocketsHttpHandler> kvp in _handlers)
193203
{
194204
if (_handlers.TryRemove(kvp.Key, out SocketsHttpHandler? handler))
195205
handler.Dispose();

src/OptionsFactory.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Soenneker.Dtos.HttpClientOptions;
2+
using System;
3+
using System.Runtime.CompilerServices;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace Soenneker.Utils.HttpClientCache;
8+
9+
internal readonly struct OptionsFactory
10+
{
11+
private readonly byte _kind; // 0=null, 1=token async, 2=sync, 3=async
12+
private readonly Func<CancellationToken, ValueTask<HttpClientOptions?>>? _tokenAsync;
13+
private readonly Func<HttpClientOptions?>? _sync;
14+
private readonly Func<ValueTask<HttpClientOptions?>>? _async;
15+
16+
private OptionsFactory(byte kind, Func<CancellationToken, ValueTask<HttpClientOptions?>>? tokenAsync, Func<HttpClientOptions?>? sync,
17+
Func<ValueTask<HttpClientOptions?>>? async)
18+
{
19+
_kind = kind;
20+
_tokenAsync = tokenAsync;
21+
_sync = sync;
22+
_async = async;
23+
}
24+
25+
public static OptionsFactory Null => default;
26+
27+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
28+
public static OptionsFactory From(Func<CancellationToken, ValueTask<HttpClientOptions?>> factory) =>
29+
new(kind: 1, tokenAsync: factory, sync: null, async: null);
30+
31+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
32+
public static OptionsFactory From(Func<HttpClientOptions?> factory) =>
33+
new(kind: 2, tokenAsync: null, sync: factory, async: null);
34+
35+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
36+
public static OptionsFactory From(Func<ValueTask<HttpClientOptions?>> factory) =>
37+
new(kind: 3, tokenAsync: null, sync: null, async: factory);
38+
39+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
40+
public ValueTask<HttpClientOptions?> Invoke(CancellationToken cancellationToken)
41+
{
42+
return _kind switch
43+
{
44+
0 => default,
45+
1 => _tokenAsync!(cancellationToken),
46+
2 => new ValueTask<HttpClientOptions?>(_sync!()),
47+
3 => _async!(),
48+
_ => default
49+
};
50+
}
51+
}

0 commit comments

Comments
 (0)