44using System . Linq ;
55using System . Net ;
66using System . Net . Http ;
7+ using System . Net . Sockets ;
8+ using System . IO ;
79using System . Security . Cryptography . X509Certificates ;
810using System . Text ;
911using System . Threading ;
1012using System . Threading . Tasks ;
1113using Volo . Abp ;
1214using Newtonsoft . Json ;
15+ using System . Security . Authentication ;
1316
1417namespace 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