Skip to content

Commit 2affc63

Browse files
committed
fix: do not dispose caller-supplied HttpClients
1 parent 3cb4d8e commit 2affc63

6 files changed

Lines changed: 103 additions & 23 deletions

File tree

‎CHANGELOG.md‎

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

33
### 2026-10-06 v6.0.1
44

5+
* fix: ForceClient, JsonClient and AuthenticationClient no longer dispose an HttpClient passed in by the caller - the caller owns it
6+
- AuthenticationClient still disposes the HttpClient it creates when none is passed
7+
- ForceClient now disposes the AuthenticationClient it uses internally to log in
8+
59
### 2026-10-06 v6.0.0
610

711
* **breaking** build: remove EOL target frameworks

‎docs/auth.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ 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.
127+
126128
---
127129

128130
## Error Handling
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
/// <summary>
11+
/// A caller-supplied HttpClient is owned by the caller, and should still be usable after the client using it is disposed
12+
/// </summary>
13+
public class DisposeTests
14+
{
15+
const string TestUrl = "http://example.org/test";
16+
17+
[Fact]
18+
public async Task ForceClientDisposeLeavesCallerHttpClientUsable()
19+
{
20+
HttpClient httpClient = CreateHttpClient();
21+
22+
ForceClient client = new ForceClient("https://na15.salesforce.com", "v57.0", "dummyToken", httpClient);
23+
client.Dispose();
24+
25+
await AssertHttpClientUsable(httpClient);
26+
}
27+
28+
[Fact]
29+
public async Task JsonClientDisposeLeavesCallerHttpClientUsable()
30+
{
31+
HttpClient httpClient = CreateHttpClient();
32+
33+
JsonClient client = new JsonClient("dummyToken", httpClient);
34+
client.Dispose();
35+
36+
await AssertHttpClientUsable(httpClient);
37+
}
38+
39+
[Fact]
40+
public async Task AuthenticationClientDisposeLeavesCallerHttpClientUsable()
41+
{
42+
HttpClient httpClient = CreateHttpClient();
43+
44+
AuthenticationClient client = new AuthenticationClient(httpClient: httpClient);
45+
client.Dispose();
46+
47+
await AssertHttpClientUsable(httpClient);
48+
}
49+
50+
private static HttpClient CreateHttpClient()
51+
{
52+
var mockHandler = new MockHttpClientHandler();
53+
mockHandler.AddMockResponse(new Uri(TestUrl), new HttpResponseMessage(HttpStatusCode.OK));
54+
55+
return new HttpClient(mockHandler);
56+
}
57+
58+
private static async Task AssertHttpClientUsable(HttpClient httpClient)
59+
{
60+
// throws ObjectDisposedException if the HttpClient was disposed
61+
var response = await httpClient.GetAsync(TestUrl);
62+
63+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
64+
}
65+
}
66+
}

‎src/NetCoreForce.Client/AuthenticationClient.cs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class AuthenticationClient : IDisposable
2424
private const string IntrospectTokenEndpointUrl = "https://login.salesforce.com/services/oauth2/introspect";
2525
private const string TokenRequestEndpointUrl = "https://login.salesforce.com/services/oauth2/token";
2626
private readonly HttpClient _httpClient;
27+
private readonly bool _disposeHttpClient;
2728

2829
/// <summary>
2930
/// Initialize the AuthenticationClient with the libary's default Salesforce API version, and default HttpClient
@@ -35,7 +36,7 @@ public AuthenticationClient() : this(null, null) { }
3536
/// Initialize the AuthenticationClient with the specified Salesforce API version and/or HttpClient
3637
/// </summary>
3738
/// <param name="apiVersion">Target Salesforce API version</param>
38-
/// <param name="httpClient">Custom HttpClient (Optional)</param>
39+
/// <param name="httpClient">Custom HttpClient (Optional). Not disposed by this client; the caller retains ownership.</param>
3940
public AuthenticationClient(string apiVersion = null, HttpClient httpClient = null)
4041
{
4142
if (!string.IsNullOrEmpty(apiVersion))
@@ -47,6 +48,8 @@ public AuthenticationClient(string apiVersion = null, HttpClient httpClient = nu
4748
ApiVersion = DefaultApiVersion;
4849
}
4950

51+
//only dispose the HttpClient if this client created it
52+
_disposeHttpClient = httpClient == null;
5053
_httpClient = httpClient ?? new HttpClient();
5154
}
5255

@@ -365,9 +368,16 @@ public async Task ClientCredentialsAsync(string clientId, string clientSecret,st
365368
}
366369
}
367370

371+
/// <summary>
372+
/// Dispose client - only disposes the HttpClient if this client created it.
373+
/// <para>A caller-supplied HttpClient is owned by the caller, and is left as-is.</para>
374+
/// </summary>
368375
public void Dispose()
369376
{
370-
_httpClient.Dispose();
377+
if (_disposeHttpClient)
378+
{
379+
_httpClient.Dispose();
380+
}
371381
}
372382
}
373383
}

‎src/NetCoreForce.Client/ForceClient.cs‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,25 +113,31 @@ public ForceClient(string instanceUrl, string apiVersion, string accessToken, Ht
113113
/// <param name="httpClient">Optional HttpClient object. Defaults to a shared static instance for best performance, but a custom HttpClient can be specified when custom properties are needed e.g. proxy settings.</param>
114114
public static async Task<ForceClient> FromClientCredentialsAsync(string clientId, string clientSecret, string tokenRequestEndpoint, string apiVersion = null, HttpClient httpClient = null)
115115
{
116-
AuthenticationClient authClient = new AuthenticationClient(apiVersion, httpClient);
117-
await authClient.ClientCredentialsAsync(clientId, clientSecret, tokenRequestEndpoint).ConfigureAwait(false);
118-
return new ForceClient(authClient.AccessInfo.InstanceUrl, authClient.ApiVersion, authClient.AccessInfo.AccessToken, httpClient, authClient.AccessInfo);
116+
using (AuthenticationClient authClient = new AuthenticationClient(apiVersion, httpClient))
117+
{
118+
await authClient.ClientCredentialsAsync(clientId, clientSecret, tokenRequestEndpoint).ConfigureAwait(false);
119+
return new ForceClient(authClient.AccessInfo.InstanceUrl, authClient.ApiVersion, authClient.AccessInfo.AccessToken, httpClient, authClient.AccessInfo);
120+
}
119121
}
120122

121123
private async Task Login(string clientId, string clientSecret, string username, string password, string tokenRequestEndpoint, string apiVersion = null, HttpClient httpClient = null)
122124
{
123-
AuthenticationClient authClient = new AuthenticationClient(apiVersion, httpClient);
124-
await authClient.UsernamePasswordAsync(clientId, clientSecret, username, password, tokenRequestEndpoint).ConfigureAwait(false);
125+
using (AuthenticationClient authClient = new AuthenticationClient(apiVersion, httpClient))
126+
{
127+
await authClient.UsernamePasswordAsync(clientId, clientSecret, username, password, tokenRequestEndpoint).ConfigureAwait(false);
125128

126-
Initialize(authClient.AccessInfo.InstanceUrl, authClient.ApiVersion, authClient.AccessInfo.AccessToken, httpClient, authClient.AccessInfo);
129+
Initialize(authClient.AccessInfo.InstanceUrl, authClient.ApiVersion, authClient.AccessInfo.AccessToken, httpClient, authClient.AccessInfo);
130+
}
127131
}
128132

129133
private async Task LoginClientCredentials(string clientId, string clientSecret, string tokenRequestEndpoint, string apiVersion = null, HttpClient httpClient = null)
130134
{
131-
AuthenticationClient authClient = new AuthenticationClient(apiVersion, httpClient);
132-
await authClient.ClientCredentialsAsync(clientId, clientSecret, tokenRequestEndpoint).ConfigureAwait(false);
135+
using (AuthenticationClient authClient = new AuthenticationClient(apiVersion, httpClient))
136+
{
137+
await authClient.ClientCredentialsAsync(clientId, clientSecret, tokenRequestEndpoint).ConfigureAwait(false);
133138

134-
Initialize(authClient.AccessInfo.InstanceUrl, authClient.ApiVersion, authClient.AccessInfo.AccessToken, httpClient, authClient.AccessInfo);
139+
Initialize(authClient.AccessInfo.InstanceUrl, authClient.ApiVersion, authClient.AccessInfo.AccessToken, httpClient, authClient.AccessInfo);
140+
}
135141
}
136142

137143
private void Initialize(string instanceUrl, string apiVersion, string accessToken, HttpClient httpClient = null, AccessTokenResponse accessInfo = null)
@@ -988,15 +994,11 @@ public async Task<DescribeGlobal> DescribeGlobal()
988994
#endregion
989995

990996
/// <summary>
991-
/// Dispose client - only disposes instance HttpClient, if any. Shared static HttpClient is left as-is.
997+
/// Dispose client - does not dispose the HttpClient.
998+
/// <para>A caller-supplied HttpClient is owned by the caller, and the shared static HttpClient is left as-is.</para>
992999
/// </summary>
9931000
public void Dispose()
9941001
{
995-
//only dispose instance member, if any
996-
if (_httpClient != null)
997-
{
998-
_httpClient.Dispose();
999-
}
10001002
}
10011003
}
10021004
}

‎src/NetCoreForce.Client/JsonClient.cs‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,15 +355,11 @@ private IEnumerable<string> GetHeaderValues(HttpHeaders headers, string headerNa
355355
}
356356

357357
/// <summary>
358-
/// Dispose client - only disposes instance HttpClient, if any. Shared static HttpClient is left as-is.
358+
/// Dispose client - does not dispose the HttpClient.
359+
/// <para>A caller-supplied HttpClient is owned by the caller, and the shared static HttpClient is left as-is.</para>
359360
/// </summary>
360361
public void Dispose()
361362
{
362-
//only dispose instance member, if any
363-
if (_httpClient != null)
364-
{
365-
_httpClient.Dispose();
366-
}
367363
}
368364
}
369365
}

0 commit comments

Comments
 (0)