From fe3c646006caef7f2f4563a4e62712471fe2cba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Trung=20Hi=E1=BA=BFu?= Date: Wed, 8 Jul 2026 09:12:28 +0700 Subject: [PATCH] feat: add HTTP retry logic to update client - Add HttpRetryPolicy with exponential backoff for transient failures. - Retry on 5xx status codes, HttpRequestException, and TaskCanceledException timeouts. - Do not retry on 4xx client errors or explicit cancellation. - Integrate retries into HttpUpdateClient.DownloadStringAsync and DownloadFileAsync. - Add 7 unit tests; dotnet test passes 66/66. Closes #45. --- src/HttpRetryPolicy.cs | 59 +++++++++++++++ src/KeePassAutoReload.csproj | 1 + src/UpdateChecker.cs | 10 ++- tests/KeePassAutoReload.Tests.csproj | 1 + tests/Tests.cs | 107 +++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/HttpRetryPolicy.cs diff --git a/src/HttpRetryPolicy.cs b/src/HttpRetryPolicy.cs new file mode 100644 index 0000000..1722b1b --- /dev/null +++ b/src/HttpRetryPolicy.cs @@ -0,0 +1,59 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace KeePassAutoReload +{ + internal static class HttpRetryPolicy + { + public const int DefaultMaxRetries = 3; + + public static async Task ExecuteAsync(Func> operation, int maxRetries, CancellationToken cancellationToken = default) + { + if (operation == null) throw new ArgumentNullException("operation"); + if (maxRetries < 0) throw new ArgumentOutOfRangeException("maxRetries"); + + cancellationToken.ThrowIfCancellationRequested(); + + Exception lastException = null; + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + HttpResponseMessage response = await operation(); + if ((int)response.StatusCode >= 500 && (int)response.StatusCode < 600) + { + response.Dispose(); + if (attempt == maxRetries) + { + throw new HttpRequestException("Server returned " + (int)response.StatusCode + " after " + maxRetries + " retries."); + } + await DelayAsync(attempt, cancellationToken); + continue; + } + return response; + } + catch (HttpRequestException ex) when (attempt < maxRetries) + { + lastException = ex; + await DelayAsync(attempt, cancellationToken); + } + catch (TaskCanceledException ex) when (attempt < maxRetries && !cancellationToken.IsCancellationRequested) + { + lastException = ex; + await DelayAsync(attempt, cancellationToken); + } + } + + throw lastException ?? new HttpRequestException("Request failed after retries."); + } + + private static async Task DelayAsync(int attempt, CancellationToken cancellationToken) + { + int milliseconds = (int)(100 * Math.Pow(2, attempt)); + await Task.Delay(milliseconds, cancellationToken); + } + } +} diff --git a/src/KeePassAutoReload.csproj b/src/KeePassAutoReload.csproj index c137007..ae13dbd 100644 --- a/src/KeePassAutoReload.csproj +++ b/src/KeePassAutoReload.csproj @@ -29,6 +29,7 @@ + diff --git a/src/UpdateChecker.cs b/src/UpdateChecker.cs index 4edf880..a7d5cf8 100644 --- a/src/UpdateChecker.cs +++ b/src/UpdateChecker.cs @@ -54,7 +54,10 @@ public HttpUpdateClient() public async Task DownloadStringAsync(string url, CancellationToken cancellationToken = default) { - using (HttpResponseMessage response = await _client.GetAsync(url, cancellationToken)) + using (HttpResponseMessage response = await HttpRetryPolicy.ExecuteAsync( + () => _client.GetAsync(url, cancellationToken), + HttpRetryPolicy.DefaultMaxRetries, + cancellationToken)) { response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); @@ -63,7 +66,10 @@ public async Task DownloadStringAsync(string url, CancellationToken canc public async Task DownloadFileAsync(string url, string destinationPath, CancellationToken cancellationToken = default) { - using (HttpResponseMessage response = await _client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + using (HttpResponseMessage response = await HttpRetryPolicy.ExecuteAsync( + () => _client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken), + HttpRetryPolicy.DefaultMaxRetries, + cancellationToken)) { response.EnsureSuccessStatusCode(); byte[] data = await response.Content.ReadAsByteArrayAsync(); diff --git a/tests/KeePassAutoReload.Tests.csproj b/tests/KeePassAutoReload.Tests.csproj index 05cb3e4..6daeb08 100644 --- a/tests/KeePassAutoReload.Tests.csproj +++ b/tests/KeePassAutoReload.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/Tests.cs b/tests/Tests.cs index 30fa440..e004525 100644 --- a/tests/Tests.cs +++ b/tests/Tests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Net; +using System.Net.Http; using System.Security.Authentication; using System.Threading; using System.Threading.Tasks; @@ -76,6 +77,112 @@ public void AboutTextIncludesVersionAndCurrentSettings() } } + public class HttpRetryPolicyTests + { + private static Task SuccessResponse(HttpStatusCode statusCode) + { + return Task.FromResult(new HttpResponseMessage(statusCode)); + } + + [Fact] + public async Task ExecuteAsync_ReturnsResponseOnFirstSuccess() + { + HttpResponseMessage response = await HttpRetryPolicy.ExecuteAsync( + () => SuccessResponse(HttpStatusCode.OK), + 3); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ExecuteAsync_RetriesOnServerErrorThenSucceeds() + { + int callCount = 0; + HttpResponseMessage response = await HttpRetryPolicy.ExecuteAsync(() => + { + callCount++; + if (callCount < 3) return SuccessResponse(HttpStatusCode.ServiceUnavailable); + return SuccessResponse(HttpStatusCode.OK); + }, 3); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, callCount); + } + + [Fact] + public async Task ExecuteAsync_DoesNotRetryOnClientError() + { + int callCount = 0; + HttpResponseMessage response = await HttpRetryPolicy.ExecuteAsync(() => + { + callCount++; + return SuccessResponse(HttpStatusCode.NotFound); + }, 3); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task ExecuteAsync_ThrowsAfterMaxRetriesOnServerError() + { + int callCount = 0; + await Assert.ThrowsAsync(() => HttpRetryPolicy.ExecuteAsync(() => + { + callCount++; + return SuccessResponse(HttpStatusCode.InternalServerError); + }, 2)); + + Assert.Equal(3, callCount); + } + + [Fact] + public async Task ExecuteAsync_RetriesOnTransientExceptionThenSucceeds() + { + int callCount = 0; + HttpResponseMessage response = await HttpRetryPolicy.ExecuteAsync(() => + { + callCount++; + if (callCount < 2) throw new HttpRequestException("transient"); + return SuccessResponse(HttpStatusCode.OK); + }, 3); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, callCount); + } + + [Fact] + public async Task ExecuteAsync_ThrowsOnCancellationWithoutRetry() + { + int callCount = 0; + using (CancellationTokenSource cts = new CancellationTokenSource()) + { + cts.Cancel(); + await Assert.ThrowsAsync(() => HttpRetryPolicy.ExecuteAsync(() => + { + callCount++; + return SuccessResponse(HttpStatusCode.OK); + }, 3, cts.Token)); + } + + Assert.Equal(0, callCount); + } + + [Fact] + public void ExecuteAsync_ThrowsWhenOperationIsNull() + { + Assert.Throws(() => + HttpRetryPolicy.ExecuteAsync(null, 3).GetAwaiter().GetResult()); + } + + [Fact] + public void ExecuteAsync_ThrowsWhenMaxRetriesIsNegative() + { + Assert.Throws(() => + HttpRetryPolicy.ExecuteAsync(() => SuccessResponse(HttpStatusCode.OK), -1).GetAwaiter().GetResult()); + } + } + public class UpdateCheckerTests { [Fact]