Skip to content

Commit ff35c32

Browse files
committed
fix: HttpClients created by HttpClientFactory.CreateHttpClient (including the shared static clients) now replace pooled connections every 2 minutes, so long-running processes pick up DNS changes
1 parent 54fb49e commit ff35c32

5 files changed

Lines changed: 174 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
* fix: ForceClient, JsonClient and AuthenticationClient no longer dispose an HttpClient passed in by the caller - the caller owns it
66
* fix: AuthenticationClient now uses a shared static HttpClient by default, like ForceClient and JsonClient, instead of creating a new HttpClient (and connection pool) per instance - repeated logins no longer leak sockets
7+
* fix: HttpClients created by HttpClientFactory.CreateHttpClient (including the shared static clients) now replace pooled connections every 2 minutes, so long-running processes pick up DNS changes
8+
- .NET 8+: uses SocketsHttpHandler with PooledConnectionLifetime (except on browser and mobile platforms, which keep their native handler)
9+
- .NET Framework: sets ServicePoint.ConnectionLeaseTimeout for each request's host
10+
- .NET Standard builds are unchanged
711

812
### 2026-10-06 v6.0.0
913

‎docs/auth.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ HttpClient proxyClient = HttpClientFactory.CreateHttpClient(true, "http://your-p
123123
ForceClient client = new ForceClient("your-client-id", "your-client-secret", "your-username", "your-password", "https://login.salesforce.com/services/oauth2/token", httpClient: proxyClient);
124124
```
125125

126-
The caller owns an `HttpClient` passed in this way - disposing `ForceClient`, `JsonClient` or `AuthenticationClient` does not dispose it, so a single long-lived instance can be shared across clients.
126+
The caller owns an `HttpClient` passed in this way - disposing `ForceClient`, `JsonClient` or `AuthenticationClient` does not dispose it, so a single long-lived instance can be shared across clients. Clients built by `HttpClientFactory.CreateHttpClient` periodically replace their pooled connections so DNS changes are picked up; if you build your own `HttpClient`, configure that yourself (e.g. `SocketsHttpHandler.PooledConnectionLifetime`).
127127

128128
---
129129

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading.Tasks;
5+
using NetCoreForce.Client;
6+
using Xunit;
7+
8+
namespace NetCoreForce.Client.Tests
9+
{
10+
public class HttpClientFactoryTests
11+
{
12+
[Fact]
13+
public void CreateHttpClientRequestsCompression()
14+
{
15+
HttpClient httpClient = HttpClientFactory.CreateHttpClient();
16+
17+
Assert.Contains(httpClient.DefaultRequestHeaders.AcceptEncoding, e => e.Value == "gzip");
18+
Assert.Contains(httpClient.DefaultRequestHeaders.AcceptEncoding, e => e.Value == "deflate");
19+
}
20+
21+
#if NET
22+
[Fact]
23+
public void CreateHandlerSetsPooledConnectionLifetime()
24+
{
25+
// without a connection lifetime, a long-lived HttpClient never picks up DNS changes
26+
bool decompress;
27+
HttpMessageHandler handler = HttpClientFactory.CreateHandler(true, null, out decompress);
28+
29+
SocketsHttpHandler socketsHandler = Assert.IsType<SocketsHttpHandler>(handler);
30+
Assert.Equal(HttpClientFactory.ConnectionLifetime, socketsHandler.PooledConnectionLifetime);
31+
Assert.Equal(DecompressionMethods.GZip | DecompressionMethods.Deflate, socketsHandler.AutomaticDecompression);
32+
Assert.True(decompress);
33+
}
34+
35+
[Fact]
36+
public void CreateHandlerSetsProxy()
37+
{
38+
bool decompress;
39+
HttpMessageHandler handler = HttpClientFactory.CreateHandler(false, "http://proxy.example.org:8080", out decompress);
40+
41+
SocketsHttpHandler socketsHandler = Assert.IsType<SocketsHttpHandler>(handler);
42+
Assert.IsType<CustomProxy>(socketsHandler.Proxy);
43+
Assert.Equal(DecompressionMethods.None, socketsHandler.AutomaticDecompression);
44+
Assert.False(decompress);
45+
}
46+
#endif
47+
48+
#if NETFRAMEWORK
49+
[Fact]
50+
public void CreateHandlerWrapsWithConnectionLeaseHandler()
51+
{
52+
bool decompress;
53+
HttpMessageHandler handler = HttpClientFactory.CreateHandler(true, null, out decompress);
54+
55+
Assert.IsType<HttpClientFactory.ConnectionLeaseHandler>(handler);
56+
}
57+
58+
[Fact]
59+
public async Task ConnectionLeaseHandlerSetsServicePointLeaseTimeout()
60+
{
61+
// without a lease timeout, ServicePoint keeps connections open indefinitely and never picks up DNS changes
62+
Uri uri = new Uri("http://lease.example.org/test");
63+
64+
var mockHandler = new MockHttpClientHandler();
65+
mockHandler.AddMockResponse(uri, new HttpResponseMessage(HttpStatusCode.OK));
66+
67+
HttpClient httpClient = new HttpClient(new HttpClientFactory.ConnectionLeaseHandler(mockHandler));
68+
await httpClient.GetAsync(uri);
69+
70+
Assert.Equal((int)HttpClientFactory.ConnectionLifetime.TotalMilliseconds, ServicePointManager.FindServicePoint(uri).ConnectionLeaseTimeout);
71+
}
72+
#endif
73+
}
74+
}

‎src/NetCoreForce.Client/HttpClientFactory.cs‎

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,110 @@ public class HttpClientFactory
2121
private const string GZipEncoding = "gzip";
2222
private const string DeflateEncoding = "deflate";
2323

24+
/// <summary>
25+
/// How long a pooled connection is reused before being replaced.
26+
/// <para>HttpClient instances are long-lived, so without this, connections are kept open indefinitely and DNS changes are never picked up.</para>
27+
/// </summary>
28+
internal static readonly TimeSpan ConnectionLifetime = TimeSpan.FromMinutes(2);
29+
30+
/// <summary>
31+
/// Create an HttpClient, intended to be long-lived and shared.
32+
/// <para>On .NET 8+ and .NET Framework, pooled connections are periodically replaced so DNS changes are picked up.</para>
33+
/// </summary>
34+
/// <param name="useCompression">Request gzip/deflate compressed responses</param>
35+
/// <param name="proxyUrl">Proxy URL (Optional)</param>
2436
public static HttpClient CreateHttpClient(bool useCompression = true, string proxyUrl = null)
2537
{
38+
bool decompress;
39+
HttpMessageHandler handler = CreateHandler(useCompression, proxyUrl, out decompress);
40+
41+
HttpClient httpClient = new HttpClient(handler);
42+
43+
if (decompress)
44+
{
45+
httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue(GZipEncoding));
46+
httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue(DeflateEncoding));
47+
}
48+
49+
return httpClient;
50+
}
51+
52+
internal static HttpMessageHandler CreateHandler(bool useCompression, string proxyUrl, out bool decompress)
53+
{
54+
#if NET
55+
if (UseSocketsHttpHandler())
56+
{
57+
var socketsHandler = new SocketsHttpHandler
58+
{
59+
PooledConnectionLifetime = ConnectionLifetime
60+
};
61+
62+
decompress = useCompression;
63+
if (decompress)
64+
{
65+
socketsHandler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
66+
}
67+
68+
if (!string.IsNullOrEmpty(proxyUrl))
69+
{
70+
socketsHandler.Proxy = new CustomProxy(proxyUrl);
71+
}
72+
73+
return socketsHandler;
74+
}
75+
#endif
76+
2677
var handler = new HttpClientHandler();
27-
if (useCompression && handler.SupportsAutomaticDecompression)
78+
79+
decompress = useCompression && handler.SupportsAutomaticDecompression;
80+
if (decompress)
2881
{
2982
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
3083
}
3184

32-
if(!string.IsNullOrEmpty(proxyUrl))
85+
if (!string.IsNullOrEmpty(proxyUrl))
3386
{
3487
handler.Proxy = new CustomProxy(proxyUrl);
3588
}
3689

37-
HttpClient httpClient = new HttpClient(handler);
90+
#if NETFRAMEWORK
91+
return new ConnectionLeaseHandler(handler);
92+
#else
93+
return handler;
94+
#endif
95+
}
96+
97+
#if NET
98+
/// <summary>
99+
/// On browser and mobile platforms HttpClientHandler wraps the platform's native handler, which manages its own connections - keep it there.
100+
/// </summary>
101+
private static bool UseSocketsHttpHandler()
102+
{
103+
return SocketsHttpHandler.IsSupported
104+
&& !OperatingSystem.IsAndroid()
105+
&& !OperatingSystem.IsIOS()
106+
&& !OperatingSystem.IsTvOS()
107+
&& !OperatingSystem.IsMacCatalyst();
108+
}
109+
#endif
110+
111+
#if NETFRAMEWORK
112+
/// <summary>
113+
/// On .NET Framework, HttpClientHandler connections are managed by ServicePoint, which keeps them open indefinitely by default.
114+
/// <para>Sets a connection lease timeout on the ServicePoint for each request's host, so connections are periodically replaced and DNS changes are picked up.</para>
115+
/// </summary>
116+
internal class ConnectionLeaseHandler : DelegatingHandler
117+
{
118+
public ConnectionLeaseHandler(HttpMessageHandler innerHandler) : base(innerHandler) { }
38119

39-
if (useCompression && handler.SupportsAutomaticDecompression)
120+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
40121
{
41-
httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue(GZipEncoding));
42-
httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue(DeflateEncoding));
43-
}
122+
//set on every request, since an idle ServicePoint is discarded and recreated with the default timeout
123+
ServicePointManager.FindServicePoint(request.RequestUri).ConnectionLeaseTimeout = (int)ConnectionLifetime.TotalMilliseconds;
44124

45-
return httpClient;
125+
return base.SendAsync(request, cancellationToken);
126+
}
46127
}
128+
#endif
47129
}
48-
}
130+
}

‎src/NetCoreForce.Client/NetCoreForce.Client.csproj‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
<None Include="README.md" Pack="true" PackagePath="\" />
1919
</ItemGroup>
2020

21+
<ItemGroup>
22+
<InternalsVisibleTo Include="NetCoreForce.Client.Tests" />
23+
</ItemGroup>
24+
2125
<PropertyGroup>
2226
<!-- Documentation -->
2327
<GenerateDocumentationFile>true</GenerateDocumentationFile>

0 commit comments

Comments
 (0)