Skip to content

Commit 39919d8

Browse files
fix: Add backoff logic to the HTTP clients; only update cache when remote fetch happens.
1 parent 8ad3196 commit 39919d8

File tree

3 files changed

+99
-8
lines changed

3 files changed

+99
-8
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace GDVM.Http;
8+
9+
internal sealed class ExponentialBackoffHandler : DelegatingHandler
10+
{
11+
private readonly TimeSpan _initialDelay;
12+
private readonly int _maxRetries;
13+
14+
public ExponentialBackoffHandler(TimeSpan initialDelay, int maxRetries)
15+
{
16+
if (maxRetries < 0)
17+
{
18+
throw new ArgumentOutOfRangeException(nameof(maxRetries));
19+
}
20+
21+
_initialDelay = initialDelay;
22+
_maxRetries = maxRetries;
23+
}
24+
25+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
26+
{
27+
var attempt = 0;
28+
var delay = _initialDelay;
29+
30+
while (true)
31+
{
32+
cancellationToken.ThrowIfCancellationRequested();
33+
34+
try
35+
{
36+
var response = await base.SendAsync(request, cancellationToken);
37+
38+
if (!ShouldRetry(response.StatusCode) || attempt >= _maxRetries)
39+
{
40+
return response;
41+
}
42+
43+
response.Dispose();
44+
}
45+
catch (HttpRequestException) when (attempt < _maxRetries)
46+
{
47+
// swallowed intentionally to retry
48+
}
49+
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < _maxRetries)
50+
{
51+
// Timeout or transient cancellation; retry
52+
if (ex.InnerException is OperationCanceledException && cancellationToken.IsCancellationRequested)
53+
{
54+
throw;
55+
}
56+
}
57+
58+
attempt++;
59+
60+
try
61+
{
62+
await Task.Delay(delay, cancellationToken);
63+
}
64+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
65+
{
66+
throw;
67+
}
68+
69+
delay = TimeSpan.FromTicks(delay.Ticks * 2);
70+
}
71+
}
72+
73+
private static bool ShouldRetry(HttpStatusCode statusCode) =>
74+
statusCode == HttpStatusCode.RequestTimeout ||
75+
statusCode == (HttpStatusCode)429 || // Too Many Requests
76+
(int)statusCode >= 500;
77+
}

GDVM.CLI/Program.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using GDVM.Environment;
44
using GDVM.Filter;
55
using GDVM.Godot;
6+
using GDVM.Http;
67
using GDVM.Progress;
78
using GDVM.Services;
89
using Microsoft.Extensions.Configuration;
@@ -65,15 +66,19 @@ public static int Main(string[] args)
6566
var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
6667
client.DefaultRequestHeaders.UserAgent.Clear();
6768
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("gdvm", version));
68-
});
69+
client.Timeout = TimeSpan.FromSeconds(30);
70+
})
71+
.AddHttpMessageHandler(() => new ExponentialBackoffHandler(TimeSpan.FromSeconds(2), 3));
6972

7073
services.AddHttpClient<ITuxFamilyClient, TuxFamilyClient>("tuxfamily")
7174
.ConfigureHttpClient((_, client) =>
7275
{
7376
var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
7477
client.DefaultRequestHeaders.UserAgent.Clear();
7578
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("gdvm", version));
76-
});
79+
client.Timeout = TimeSpan.FromSeconds(30);
80+
})
81+
.AddHttpMessageHandler(() => new ExponentialBackoffHandler(TimeSpan.FromSeconds(2), 3));
7782

7883
// Register core services
7984
services.AddSingleton<IDownloadClient, DownloadClient>();

GDVM/Services/InstallationService.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ public async Task<string[]> FetchReleaseNames(CancellationToken cancellationToke
237237
var lastWriteTime = File.GetLastWriteTime(pathService.ReleasesPath);
238238
var isCacheValid = !remote && File.Exists(pathService.ReleasesPath) && DateTime.Now.AddDays(-1) <= lastWriteTime;
239239

240-
IEnumerable<string> releaseNames = [];
240+
string[] releaseNames;
241+
var fetchedRemote = false;
241242

242243
if (isCacheValid)
243244
{
@@ -247,13 +248,17 @@ public async Task<string[]> FetchReleaseNames(CancellationToken cancellationToke
247248
{
248249
releaseNames = cachedReleases;
249250
}
250-
251-
logger.LogWarning("Cached releases file is empty, fetching from remote");
251+
else
252+
{
253+
logger.LogWarning("Cached releases file is empty, fetching from remote");
254+
releaseNames = (await releaseManager.ListReleases(cancellationToken)).ToArray();
255+
fetchedRemote = true;
256+
}
252257
}
253258
else
254259
{
255-
releaseNames = await releaseManager
256-
.ListReleases(cancellationToken);
260+
releaseNames = (await releaseManager.ListReleases(cancellationToken)).ToArray();
261+
fetchedRemote = true;
257262
}
258263

259264
// Always sort releases using Release.CompareTo for consistent ordering
@@ -270,7 +275,11 @@ public async Task<string[]> FetchReleaseNames(CancellationToken cancellationToke
270275
return [];
271276
}
272277

273-
await File.WriteAllLinesAsync(pathService.ReleasesPath, sortedReleases, cancellationToken);
278+
if (fetchedRemote)
279+
{
280+
await File.WriteAllLinesAsync(pathService.ReleasesPath, sortedReleases, cancellationToken);
281+
}
282+
274283
return sortedReleases;
275284
}
276285

0 commit comments

Comments
 (0)