Skip to content

Commit ce12fd4

Browse files
feat(client): add retries support
1 parent 64e5236 commit ce12fd4

File tree

4 files changed

+177
-19
lines changed

4 files changed

+177
-19
lines changed

src/ArcadeDotnet/ArcadeClient.cs

Lines changed: 164 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Net;
26
using System.Net.Http;
37
using System.Threading;
48
using System.Threading.Tasks;
@@ -15,6 +19,13 @@ namespace ArcadeDotnet;
1519

1620
public 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()

src/ArcadeDotnet/Core/ClientOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public Uri BaseUrl
2121

2222
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);
2323

24+
public int MaxRetries { get; set; } = 2;
25+
2426
/// <summary>
2527
/// API key used for authorization in header
2628
/// </summary>

src/ArcadeDotnet/Core/HttpResponse.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ await Message.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false),
3232
}
3333
}
3434

35+
public async Task<string> ReadAsString(CancellationToken cancellationToken = default)
36+
{
37+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
38+
this.CancellationToken,
39+
cancellationToken
40+
);
41+
return await Message.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
42+
}
43+
3544
public void Dispose()
3645
{
3746
this.Message.Dispose();

src/ArcadeDotnet/IArcadeClient.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public interface IArcadeClient
2222

2323
TimeSpan Timeout { get; init; }
2424

25+
int MaxRetries { get; init; }
26+
2527
/// <summary>
2628
/// API key used for authorization in header
2729
/// </summary>

0 commit comments

Comments
 (0)