Skip to content

Commit fb3e3d5

Browse files
fix: reuse single HttpClient across SealPipeline calls (#19)
Previously SealPipeline.SealAndUploadAsync constructed two HttpClient instances per call (one for PKG, one for Cryptify), then disposed them. Under sustained load this exhausts the ephemeral socket range as the underlying connections accumulate in TIME_WAIT — the classic .NET HttpClient anti-pattern. PostGuard now owns a single long-lived HttpClient (SocketsHttpHandler with PooledConnectionLifetime = 2min so DNS changes are still picked up) and passes it down to SealPipeline. Callers can also inject their own HttpClient via PostGuardConfig.HttpClient (DI-friendly; the SDK does not dispose injected clients). A new PostGuardConfig.Timeout knob exposes the previously-hidden default 100s. Closes #11 Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
1 parent 8128f92 commit fb3e3d5

5 files changed

Lines changed: 121 additions & 21 deletions

File tree

src/Crypto/SealPipeline.cs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ internal static class SealPipeline
1515
/// </summary>
1616
public static async Task<UploadResult> SealAndUploadAsync(
1717
PostGuardConfig config,
18+
HttpClient http,
1819
EncryptInput input,
1920
UploadOptions? uploadOptions,
2021
CancellationToken ct = default)
2122
{
22-
var sealedBytes = await SealAsync(config, input, ct);
23+
var sealedBytes = await SealAsync(config, http, input, ct);
2324

24-
using var http = CreateHttpClient(config);
2525
var cryptify = new CryptifyClient(http, config.CryptifyUrl);
2626
var uuid = await cryptify.UploadAsync(
2727
sealedBytes, input.Recipients, uploadOptions?.Notify, ct);
@@ -34,14 +34,14 @@ public static async Task<UploadResult> SealAndUploadAsync(
3434
/// </summary>
3535
public static async Task<byte[]> SealAsync(
3636
PostGuardConfig config,
37+
HttpClient http,
3738
EncryptInput input,
3839
CancellationToken ct = default)
3940
{
4041
var apiKey = input.Sign is ApiKeySign ak
4142
? ak.ApiKey
4243
: throw new ArgumentException("Only ApiKey signing is supported");
4344

44-
using var http = CreateHttpClient(config);
4545
var pkg = new PkgClient(http, config.PkgUrl);
4646

4747
// Fetch MPK and signing keys in parallel
@@ -94,17 +94,4 @@ internal static string BuildPolicyJson(IReadOnlyList<RecipientBuilder> recipient
9494

9595
return JsonSerializer.Serialize(policy);
9696
}
97-
98-
private static HttpClient CreateHttpClient(PostGuardConfig config)
99-
{
100-
var http = new HttpClient();
101-
if (config.Headers != null)
102-
{
103-
foreach (var (key, value) in config.Headers)
104-
{
105-
http.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
106-
}
107-
}
108-
return http;
109-
}
11097
}

src/PostGuard.cs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,48 @@
22

33
namespace E4A.PostGuard;
44

5-
public class PostGuard
5+
public class PostGuard : IDisposable
66
{
77
private readonly PostGuardConfig _config;
8+
private readonly HttpClient _http;
9+
private readonly bool _ownsHttp;
10+
private bool _disposed;
811

912
public PostGuard(PostGuardConfig config)
1013
{
1114
ArgumentNullException.ThrowIfNull(config);
1215
config.Validate();
1316
_config = config;
17+
18+
if (config.HttpClient is not null)
19+
{
20+
_http = config.HttpClient;
21+
_ownsHttp = false;
22+
}
23+
else
24+
{
25+
// SocketsHttpHandler with a bounded PooledConnectionLifetime so a
26+
// long-lived client still picks up DNS changes — recommended pattern
27+
// for singleton HttpClient. See:
28+
// https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines
29+
var handler = new SocketsHttpHandler
30+
{
31+
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
32+
};
33+
_http = new HttpClient(handler, disposeHandler: true);
34+
if (config.Timeout is { } timeout)
35+
{
36+
_http.Timeout = timeout;
37+
}
38+
if (config.Headers is not null)
39+
{
40+
foreach (var (key, value) in config.Headers)
41+
{
42+
_http.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
43+
}
44+
}
45+
_ownsHttp = true;
46+
}
1447
}
1548

1649
/// <summary>
@@ -30,7 +63,18 @@ public PostGuard(PostGuardConfig config)
3063
/// </summary>
3164
public Sealed Encrypt(EncryptInput input)
3265
{
33-
return new Sealed(_config, input);
66+
return new Sealed(_config, _http, input);
67+
}
68+
69+
public void Dispose()
70+
{
71+
if (_disposed) return;
72+
_disposed = true;
73+
if (_ownsHttp)
74+
{
75+
_http.Dispose();
76+
}
77+
GC.SuppressFinalize(this);
3478
}
3579
}
3680

src/PostGuardConfig.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ public class PostGuardConfig
1313
/// </summary>
1414
public bool AllowInsecureUrls { get; init; }
1515

16+
/// <summary>
17+
/// Optional caller-supplied <see cref="System.Net.Http.HttpClient"/>. When
18+
/// set, the SDK reuses this client for all PKG and Cryptify calls and does
19+
/// NOT dispose it — ownership stays with the caller (DI-friendly). When
20+
/// null, <see cref="PostGuard"/> creates and owns a single long-lived client.
21+
/// </summary>
22+
public HttpClient? HttpClient { get; init; }
23+
24+
/// <summary>
25+
/// Request timeout applied to the SDK-owned <see cref="System.Net.Http.HttpClient"/>.
26+
/// Ignored when <see cref="HttpClient"/> is supplied (the caller owns the
27+
/// timeout in that case). Defaults to <see cref="System.Net.Http.HttpClient"/>'s
28+
/// own default of 100 seconds when null.
29+
/// </summary>
30+
public TimeSpan? Timeout { get; init; }
31+
1632
internal void Validate()
1733
{
1834
if (AllowInsecureUrls)

src/Sealed.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ namespace E4A.PostGuard;
1010
public class Sealed
1111
{
1212
private readonly PostGuardConfig _config;
13+
private readonly HttpClient _http;
1314
private readonly EncryptInput _input;
1415

15-
internal Sealed(PostGuardConfig config, EncryptInput input)
16+
internal Sealed(PostGuardConfig config, HttpClient http, EncryptInput input)
1617
{
1718
_config = config;
19+
_http = http;
1820
_input = input;
1921
}
2022

@@ -28,7 +30,7 @@ public async Task<UploadResult> UploadAsync(
2830
UploadOptions? options = null,
2931
CancellationToken ct = default)
3032
{
31-
return await SealPipeline.SealAndUploadAsync(_config, _input, options, ct);
33+
return await SealPipeline.SealAndUploadAsync(_config, _http, _input, options, ct);
3234
}
3335

3436
/// <summary>
@@ -38,6 +40,6 @@ public async Task<UploadResult> UploadAsync(
3840
/// <returns>The sealed (encrypted + signed) byte array.</returns>
3941
public async Task<byte[]> ToBytesAsync(CancellationToken ct = default)
4042
{
41-
return await SealPipeline.SealAsync(_config, _input, ct);
43+
return await SealPipeline.SealAsync(_config, _http, _input, ct);
4244
}
4345
}

tests/E4A.PostGuard.Tests/PostGuardConfigTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,55 @@ public void Ctor_NullConfig_Throws()
9191
{
9292
Assert.Throws<ArgumentNullException>(() => new PostGuard(null!));
9393
}
94+
95+
[Fact]
96+
public void Dispose_DoesNotDisposeInjectedHttpClient()
97+
{
98+
using var injected = new HttpClient();
99+
var config = new PostGuardConfig
100+
{
101+
PkgUrl = ValidPkg,
102+
CryptifyUrl = ValidCryptify,
103+
HttpClient = injected,
104+
};
105+
106+
var pg = new PostGuard(config);
107+
pg.Dispose();
108+
109+
// If injected was disposed, this throws ObjectDisposedException.
110+
injected.DefaultRequestHeaders.TryAddWithoutValidation("X-Test", "ok");
111+
}
112+
113+
[Fact]
114+
public void Dispose_DisposesOwnedHttpClient()
115+
{
116+
var config = new PostGuardConfig
117+
{
118+
PkgUrl = ValidPkg,
119+
CryptifyUrl = ValidCryptify,
120+
};
121+
122+
var pg = new PostGuard(config);
123+
pg.Dispose();
124+
// Calling Dispose twice must be safe.
125+
pg.Dispose();
126+
}
127+
128+
[Fact]
129+
public void Ctor_AppliesTimeoutToOwnedClient()
130+
{
131+
var config = new PostGuardConfig
132+
{
133+
PkgUrl = ValidPkg,
134+
CryptifyUrl = ValidCryptify,
135+
Timeout = TimeSpan.FromSeconds(42),
136+
};
137+
138+
using var pg = new PostGuard(config);
139+
140+
// No exception during construction; the Timeout property is applied
141+
// internally — see PostGuard ctor. We can at least verify the SDK
142+
// accepts the value without throwing.
143+
Assert.NotNull(pg);
144+
}
94145
}

0 commit comments

Comments
 (0)