Skip to content

Commit ceb3dee

Browse files
authored
Merge pull request #1742 from bcgov/dev
Dev
2 parents b0a173a + f41d4c8 commit ceb3dee

1 file changed

Lines changed: 126 additions & 51 deletions

File tree

applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs

Lines changed: 126 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
using System.Linq;
55
using System.Net;
66
using System.Net.Http;
7+
using System.Net.Sockets;
8+
using System.IO;
79
using System.Security.Cryptography.X509Certificates;
810
using System.Text;
911
using System.Threading;
1012
using System.Threading.Tasks;
1113
using Volo.Abp;
1214
using Newtonsoft.Json;
15+
using System.Security.Authentication;
1316

1417
namespace Unity.Modules.Shared.Http
1518
{
@@ -19,29 +22,45 @@ public class ResilientHttpRequest(HttpClient httpClient) : IResilientHttpRequest
1922
private static int _maxRetryAttempts = 3;
2023
private static TimeSpan _pauseBetweenFailures = TimeSpan.FromSeconds(2);
2124
private static TimeSpan _httpRequestTimeout = TimeSpan.FromSeconds(60);
25+
2226
private const string AuthorizationHeader = "Authorization";
23-
private static ResiliencePipeline<HttpResponseMessage> _pipeline = BuildPipeline();
27+
2428
private string? _baseUrl;
2529
private readonly HttpClient _httpClient = httpClient;
2630

27-
private static readonly HttpStatusCode[] RetryableStatusCodes = new[]
28-
{
31+
// Keep a cached mutual TLS HttpClient — never create per request
32+
private static readonly object _mtlsClientLock = new();
33+
private static HttpClient? _mtlsClient;
34+
35+
/// <summary>
36+
/// Status codes that qualify for retry.
37+
/// </summary>
38+
private static readonly HttpStatusCode[] RetryableStatusCodes =
39+
[
2940
HttpStatusCode.TooManyRequests,
3041
HttpStatusCode.InternalServerError,
3142
HttpStatusCode.BadGateway,
3243
HttpStatusCode.ServiceUnavailable,
3344
HttpStatusCode.GatewayTimeout
34-
};
45+
];
46+
47+
/// <summary>
48+
/// A Polly v8 pipeline for handling retries + timeout.
49+
/// </summary>
50+
private static ResiliencePipeline<HttpResponseMessage> _pipeline = BuildPipeline();
51+
52+
53+
// -------------------------------
54+
// Pipeline Configuration
55+
// -------------------------------
3556

36-
public static void SetPipelineOptions(
37-
int maxRetryAttempts,
38-
TimeSpan pauseBetweenFailures,
39-
TimeSpan httpRequestTimeout)
57+
public static void SetPipelineOptions(int maxRetryAttempts, TimeSpan pauseBetweenFailures, TimeSpan httpRequestTimeout)
4058
{
4159
_maxRetryAttempts = maxRetryAttempts;
4260
_pauseBetweenFailures = pauseBetweenFailures;
4361
_httpRequestTimeout = httpRequestTimeout;
44-
_pipeline = BuildPipeline(); // rebuild with new settings
62+
63+
_pipeline = BuildPipeline();
4564
}
4665

4766
private static ResiliencePipeline<HttpResponseMessage> BuildPipeline()
@@ -50,17 +69,30 @@ private static ResiliencePipeline<HttpResponseMessage> BuildPipeline()
5069
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
5170
{
5271
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
53-
.Handle<HttpRequestException>()
72+
.Handle<HttpRequestException>() // most HTTP/network errors
73+
.Handle<IOException>() // transport layer failures
74+
.Handle<SocketException>() // TCP reset / handshake abort
75+
.Handle<AuthenticationException>() // TLS handshake authentication failures
76+
.Handle<OperationCanceledException>() // handshake timeout
5477
.HandleResult(result => ShouldRetry(result.StatusCode)),
55-
Delay = _pauseBetweenFailures,
78+
5679
MaxRetryAttempts = _maxRetryAttempts,
57-
UseJitter = true,
58-
BackoffType = DelayBackoffType.Exponential
80+
Delay = _pauseBetweenFailures,
81+
BackoffType = DelayBackoffType.Exponential,
82+
UseJitter = true
5983
})
6084
.AddTimeout(_httpRequestTimeout)
6185
.Build();
6286
}
6387

88+
private static bool ShouldRetry(HttpStatusCode statusCode) =>
89+
RetryableStatusCodes.Contains(statusCode);
90+
91+
92+
// -------------------------------
93+
// URL handling
94+
// -------------------------------
95+
6496
public void SetBaseUrl(string baseUrl)
6597
{
6698
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out _))
@@ -70,11 +102,11 @@ public void SetBaseUrl(string baseUrl)
70102
_baseUrl = baseUrl.TrimEnd('/');
71103
}
72104

73-
private static bool ShouldRetry(HttpStatusCode statusCode) => RetryableStatusCodes.Contains(statusCode);
74105

75-
/// <summary>
76-
/// Send an HTTP request with resilience policies applied.
77-
/// </summary>
106+
// -------------------------------
107+
// HTTP Request Entry Points
108+
// -------------------------------
109+
78110
public async Task<HttpResponseMessage> HttpAsync(
79111
HttpMethod httpVerb,
80112
string resource,
@@ -83,11 +115,14 @@ public async Task<HttpResponseMessage> HttpAsync(
83115
(string username, string password)? basicAuth = null,
84116
CancellationToken cancellationToken = default)
85117
{
86-
return await SendWithClientAsync(_httpClient, httpVerb, resource, body, authToken, basicAuth, cancellationToken);
118+
return await SendWithClientAsync(
119+
_httpClient, httpVerb, resource, body, authToken, basicAuth, cancellationToken);
87120
}
88121

122+
89123
/// <summary>
90-
/// HTTP request with mutual TLS (client certificate).
124+
/// HTTPS + Client Certificate (mTLS)
125+
/// This version now *reuses* the mTLS HttpClient safely.
91126
/// </summary>
92127
public Task<HttpResponseMessage> HttpAsyncSecured(
93128
HttpMethod httpVerb,
@@ -99,32 +134,56 @@ public Task<HttpResponseMessage> HttpAsyncSecured(
99134
(string username, string password)? basicAuth = null,
100135
CancellationToken cancellationToken = default)
101136
{
102-
var handler = new HttpClientHandler
137+
EnsureMutualTlsClient(certPath, certPassword);
138+
139+
return SendWithClientAsync(
140+
_mtlsClient!, httpVerb, resource, body, authToken, basicAuth, cancellationToken);
141+
}
142+
143+
144+
// -------------------------------
145+
// Mutual TLS Client Factory
146+
// -------------------------------
147+
148+
private static void EnsureMutualTlsClient(string certPath, string? certPassword)
149+
{
150+
if (_mtlsClient != null)
151+
return;
152+
153+
lock (_mtlsClientLock)
103154
{
104-
ClientCertificateOptions = ClientCertificateOption.Manual
105-
};
155+
if (_mtlsClient != null)
156+
return;
106157

107-
X509Certificate2 clientCert = LoadCertificate(certPath, certPassword);
108-
handler.ClientCertificates.Add(clientCert);
158+
var handler = new HttpClientHandler
159+
{
160+
ClientCertificateOptions = ClientCertificateOption.Manual,
161+
// Prevent handshake failures due to slow TLS negotiation
162+
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
163+
};
109164

110-
using var securedHttpClient = new HttpClient(handler);
111-
return SendWithClientAsync(securedHttpClient, httpVerb, resource, body, authToken, basicAuth, cancellationToken);
165+
var cert = LoadCertificate(certPath, certPassword);
166+
handler.ClientCertificates.Add(cert);
167+
168+
_mtlsClient = new HttpClient(handler);
169+
}
112170
}
113171

114-
private static X509Certificate2 LoadCertificate(string certPath, string? certPassword = null)
172+
173+
private static X509Certificate2 LoadCertificate(string certPath, string? certPassword)
115174
{
116175
if (string.IsNullOrWhiteSpace(certPassword))
117176
{
118-
// Load PEM or DER certificates
119177
return X509CertificateLoader.LoadCertificateFromFile(certPath);
120178
}
121-
else
122-
{
123-
// Load PFX/PKCS12 certificates with password
124-
return X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
125-
}
179+
return X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
126180
}
127181

182+
183+
// -------------------------------
184+
// Core Send Logic
185+
// -------------------------------
186+
128187
private async Task<HttpResponseMessage> SendWithClientAsync(
129188
HttpClient client,
130189
HttpMethod httpVerb,
@@ -134,23 +193,32 @@ private async Task<HttpResponseMessage> SendWithClientAsync(
134193
(string username, string password)? basicAuth,
135194
CancellationToken cancellationToken)
136195
{
137-
// Determine final URL
196+
// Build final URL
138197
if (!Uri.TryCreate(resource, UriKind.Absolute, out Uri? fullUrl))
139198
{
140-
if (string.IsNullOrWhiteSpace(_baseUrl))
199+
if (_baseUrl == null)
141200
{
142201
throw new InvalidOperationException("Base URL must be set for relative paths.");
143202
}
144-
fullUrl = new Uri(new Uri(_baseUrl, UriKind.Absolute), resource);
203+
fullUrl = new Uri(new Uri(_baseUrl), resource);
145204
}
146205

147206
return await _pipeline.ExecuteAsync(async ct =>
148207
{
149-
using var requestMessage = BuildRequestMessage(httpVerb, fullUrl, body, authToken, basicAuth);
150-
return await client.SendAsync(requestMessage, ct);
208+
using var requestMessage =
209+
BuildRequestMessage(httpVerb, fullUrl, body, authToken, basicAuth);
210+
211+
return await client.SendAsync(requestMessage, ct)
212+
.ConfigureAwait(false);
213+
151214
}, cancellationToken);
152215
}
153216

217+
218+
// -------------------------------
219+
// Build HTTP Request Message
220+
// -------------------------------
221+
154222
private static HttpRequestMessage BuildRequestMessage(
155223
HttpMethod httpVerb,
156224
Uri fullUrl,
@@ -160,36 +228,43 @@ private static HttpRequestMessage BuildRequestMessage(
160228
{
161229
var requestMessage = new HttpRequestMessage(httpVerb, fullUrl);
162230
requestMessage.Headers.Accept.Clear();
163-
requestMessage.Headers.ConnectionClose = true;
164231

232+
// NO Connection: close — this caused constant TLS renegotiation
233+
// requestMessage.Headers.ConnectionClose = true;
234+
235+
// Bearer Token
165236
if (!string.IsNullOrWhiteSpace(authToken))
166237
{
167238
requestMessage.Headers.Remove(AuthorizationHeader);
168239
requestMessage.Headers.Add(AuthorizationHeader, $"Bearer {authToken}");
169240
}
241+
242+
// Basic Auth
170243
else if (basicAuth.HasValue)
171244
{
172-
var credentials = Convert.ToBase64String(
173-
Encoding.ASCII.GetBytes($"{basicAuth.Value.username}:{basicAuth.Value.password}")
174-
);
245+
string raw = $"{basicAuth.Value.username}:{basicAuth.Value.password}";
246+
string encoded = Convert.ToBase64String(Encoding.ASCII.GetBytes(raw));
247+
175248
requestMessage.Headers.Remove(AuthorizationHeader);
176-
requestMessage.Headers.Add(AuthorizationHeader, $"Basic {credentials}");
249+
requestMessage.Headers.Add(AuthorizationHeader, $"Basic {encoded}");
177250
}
178251

252+
// Body
179253
if (body != null)
180254
{
181-
string bodyString = body is string s ? s : JsonConvert.SerializeObject(body);
182-
requestMessage.Content = new StringContent(
183-
bodyString, Encoding.UTF8, "application/json"
184-
);
255+
string payload = body is string s ? s : JsonConvert.SerializeObject(body);
256+
requestMessage.Content = new StringContent(payload, Encoding.UTF8, "application/json");
185257
}
186258

187259
return requestMessage;
188260
}
189261

190-
public static async Task<string> ContentToStringAsync(HttpContent httpContent)
191-
{
192-
return await httpContent.ReadAsStringAsync();
193-
}
262+
263+
// -------------------------------
264+
// Misc Helpers
265+
// -------------------------------
266+
267+
public static Task<string> ContentToStringAsync(HttpContent httpContent)
268+
=> httpContent.ReadAsStringAsync();
194269
}
195270
}

0 commit comments

Comments
 (0)