11using System ;
2+ using System . Collections . Generic ;
3+ using System . IO ;
4+ using System . Linq ;
5+ using System . Net ;
26using System . Net . Http ;
37using System . Threading ;
48using System . Threading . Tasks ;
@@ -15,6 +19,13 @@ namespace ArcadeDotnet;
1519
1620public sealed class ArcadeClient : IArcadeClient
1721{
22+ static readonly ThreadLocal < Random > _threadLocalRandom = new ( ( ) => new Random ( ) ) ;
23+
24+ static Random Random
25+ {
26+ get { return _threadLocalRandom . Value ! ; }
27+ }
28+
1829 readonly ClientOptions _options ;
1930
2031 public HttpClient HttpClient
@@ -41,6 +52,12 @@ public TimeSpan Timeout
4152 init { this . _options . Timeout = value ; }
4253 }
4354
55+ public int MaxRetries
56+ {
57+ get { return this . _options . MaxRetries ; }
58+ init { this . _options . MaxRetries = value ; }
59+ }
60+
4461 public string APIKey
4562 {
4663 get { return this . _options . APIKey ; }
@@ -93,6 +110,63 @@ public async Task<HttpResponse> Execute<T>(
93110 CancellationToken cancellationToken = default
94111 )
95112 where T : ParamsBase
113+ {
114+ if ( this . MaxRetries <= 0 )
115+ {
116+ return await ExecuteOnce ( request , cancellationToken ) . ConfigureAwait ( false ) ;
117+ }
118+
119+ var retries = 0 ;
120+ while ( true )
121+ {
122+ HttpResponse ? response = null ;
123+ try
124+ {
125+ response = await ExecuteOnce ( request , cancellationToken ) . ConfigureAwait ( false ) ;
126+ }
127+ catch ( Exception e )
128+ {
129+ if ( ++ retries > this . MaxRetries || ! ShouldRetry ( e ) )
130+ {
131+ throw ;
132+ }
133+ }
134+
135+ if ( response != null && ( ++ retries > this . MaxRetries || ! ShouldRetry ( response ) ) )
136+ {
137+ if ( response . Message . IsSuccessStatusCode )
138+ {
139+ return response ;
140+ }
141+
142+ try
143+ {
144+ throw ArcadeExceptionFactory . CreateApiException (
145+ response . Message . StatusCode ,
146+ await response . ReadAsString ( cancellationToken ) . ConfigureAwait ( false )
147+ ) ;
148+ }
149+ catch ( HttpRequestException e )
150+ {
151+ throw new ArcadeIOException ( "I/O Exception" , e ) ;
152+ }
153+ finally
154+ {
155+ response . Dispose ( ) ;
156+ }
157+ }
158+
159+ var backoff = ComputeRetryBackoff ( retries , response ) ;
160+ response ? . Dispose ( ) ;
161+ await Task . Delay ( backoff , cancellationToken ) . ConfigureAwait ( false ) ;
162+ }
163+ }
164+
165+ async Task < HttpResponse > ExecuteOnce < T > (
166+ HttpRequest < T > request ,
167+ CancellationToken cancellationToken = default
168+ )
169+ where T : ParamsBase
96170 {
97171 using HttpRequestMessage requestMessage = new ( request . Method , request . Params . Url ( this ) )
98172 {
@@ -115,29 +189,100 @@ public async Task<HttpResponse> Execute<T>(
115189 )
116190 . ConfigureAwait ( false ) ;
117191 }
118- catch ( HttpRequestException e1 )
192+ catch ( HttpRequestException e )
119193 {
120- throw new ArcadeIOException ( "I/O exception" , e1 ) ;
194+ throw new ArcadeIOException ( "I/O exception" , e ) ;
121195 }
122- if ( ! responseMessage . IsSuccessStatusCode )
196+ return new ( ) { Message = responseMessage , CancellationToken = cts . Token } ;
197+ }
198+
199+ static TimeSpan ComputeRetryBackoff ( int retries , HttpResponse ? response )
200+ {
201+ TimeSpan ? apiBackoff = ParseRetryAfterMsHeader ( response ) ?? ParseRetryAfterHeader ( response ) ;
202+ if ( apiBackoff != null && apiBackoff < TimeSpan . FromMinutes ( 1 ) )
123203 {
124- try
125- {
126- throw ArcadeExceptionFactory . CreateApiException (
127- responseMessage . StatusCode ,
128- await responseMessage . Content . ReadAsStringAsync ( cts . Token ) . ConfigureAwait ( false )
129- ) ;
130- }
131- catch ( HttpRequestException e )
132- {
133- throw new ArcadeIOException ( "I/O Exception" , e ) ;
134- }
135- finally
136- {
137- responseMessage . Dispose ( ) ;
138- }
204+ // If the API asks us to wait a certain amount of time (and it's a reasonable amount), then just
205+ // do what it says.
206+ return ( TimeSpan ) apiBackoff ;
139207 }
140- return new ( ) { Message = responseMessage , CancellationToken = cts . Token } ;
208+
209+ // Apply exponential backoff, but not more than the max.
210+ var backoffSeconds = Math . Min ( 0.5 * Math . Pow ( 2.0 , retries - 1 ) , 8.0 ) ;
211+ var jitter = 1.0 - 0.25 * Random . NextDouble ( ) ;
212+ return TimeSpan . FromSeconds ( backoffSeconds * jitter ) ;
213+ }
214+
215+ static TimeSpan ? ParseRetryAfterMsHeader ( HttpResponse ? response )
216+ {
217+ IEnumerable < string > ? headerValues = null ;
218+ response ? . Message . Headers . TryGetValues ( "Retry-After-Ms" , out headerValues ) ;
219+ var headerValue = headerValues == null ? null : Enumerable . FirstOrDefault ( headerValues ) ;
220+ if ( headerValue == null )
221+ {
222+ return null ;
223+ }
224+
225+ if ( float . TryParse ( headerValue . AsSpan ( ) , out var retryAfterMs ) )
226+ {
227+ return TimeSpan . FromMilliseconds ( retryAfterMs ) ;
228+ }
229+
230+ return null ;
231+ }
232+
233+ static TimeSpan ? ParseRetryAfterHeader ( HttpResponse ? response )
234+ {
235+ IEnumerable < string > ? headerValues = null ;
236+ response ? . Message . Headers . TryGetValues ( "Retry-After" , out headerValues ) ;
237+ var headerValue = headerValues == null ? null : Enumerable . FirstOrDefault ( headerValues ) ;
238+ if ( headerValue == null )
239+ {
240+ return null ;
241+ }
242+
243+ if ( float . TryParse ( headerValue . AsSpan ( ) , out var retryAfterSeconds ) )
244+ {
245+ return TimeSpan . FromSeconds ( retryAfterSeconds ) ;
246+ }
247+ else if ( DateTimeOffset . TryParse ( headerValue . AsSpan ( ) , out var retryAfterDate ) )
248+ {
249+ return retryAfterDate - DateTimeOffset . Now ;
250+ }
251+
252+ return null ;
253+ }
254+
255+ static bool ShouldRetry ( HttpResponse response )
256+ {
257+ if (
258+ response . Message . Headers . TryGetValues ( "X-Should-Retry" , out var headerValues )
259+ && bool . TryParse ( Enumerable . FirstOrDefault ( headerValues ) , out var shouldRetry )
260+ )
261+ {
262+ // If the server explicitly says whether to retry, then we obey.
263+ return shouldRetry ;
264+ }
265+
266+ return response . Message . StatusCode switch
267+ {
268+ // Retry on request timeouts
269+ HttpStatusCode . RequestTimeout
270+ or
271+ // Retry on lock timeouts
272+ HttpStatusCode . Conflict
273+ or
274+ // Retry on rate limits
275+ HttpStatusCode . TooManyRequests
276+ or
277+ // Retry internal errors
278+ >= HttpStatusCode . InternalServerError => true ,
279+ _ => false ,
280+ } ;
281+ }
282+
283+ static bool ShouldRetry ( Exception e )
284+ {
285+ return e is IOException || e is ArcadeIOException ;
141286 }
142287
143288 public ArcadeClient ( )
0 commit comments