diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:38 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 13:10:34 -0300 |
| commit | a2b10fd494fb389e556e40134031eb86e56e11e7 (patch) | |
| tree | 08b6df8a06f2c7c15488b90d57a6d8286cd57a22 /tests/Cepis.Tests/RetryTests.cs | |
| download | csharp-a2b10fd494fb389e556e40134031eb86e56e11e7.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests/Cepis.Tests/RetryTests.cs')
| -rw-r--r-- | tests/Cepis.Tests/RetryTests.cs | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/tests/Cepis.Tests/RetryTests.cs b/tests/Cepis.Tests/RetryTests.cs new file mode 100644 index 0000000..2114a12 --- /dev/null +++ b/tests/Cepis.Tests/RetryTests.cs @@ -0,0 +1,249 @@ +namespace Cepis.Tests; + +/// <summary>The retry policy: what is retried, how long it waits, what it never retries.</summary> +public sealed class RetryTests : HttpTestBase +{ + [Fact] + public async Task ATransientFiveHundredIsRetried() + { + Server.Respond("/01001000", + Reply.Of(500, Fixtures.InternalError), + Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Equal("São Paulo", address.Localidade); + Assert.Equal(2, Server.Count("/01001000")); + } + + [Theory] + [InlineData(500)] + [InlineData(502)] + [InlineData(503)] + [InlineData(504)] + public async Task EveryTransientStatusIsRetried(int status) + { + Server.Respond("/01001000", Reply.Of(status, Fixtures.InternalError), Reply.Of(200, Fixtures.SeAddress)); + + await Client.LookupAsync("01001000"); + + Assert.Equal(2, Server.Count("/01001000")); + } + + [Fact] + public async Task ThreeAttemptsIsTheDefault() + { + Server.Respond("/01001000", Reply.Of(503, Fixtures.InternalError)); + + var failure = await Assert.ThrowsAsync<ServerErrorException>( + () => Client.LookupAsync("01001000")); + + Assert.Equal(503, failure.Status); + Assert.Equal(3, Server.Count("/01001000")); + } + + [Fact] + public async Task OneAttemptDisablesRetrying() + { + using var single = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + MaxAttempts = 1, + }); + Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError)); + + await Assert.ThrowsAsync<ServerErrorException>(() => single.LookupAsync("01001000")); + + Assert.Equal(1, Server.Count("/01001000")); + } + + [Fact] + public async Task A429BecomesRateLimitedAndCarriesRetryAfter() + { + Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "7")); + + var failure = await Assert.ThrowsAsync<RateLimitedException>( + () => Client.LookupAsync("01001000")); + + Assert.Equal(TimeSpan.FromSeconds(7), failure.RetryAfter); + } + + [Fact] + public async Task A429WithoutRetryAfterLeavesNothingToRescheduleWith() + { + Server.Respond("/01001000", Reply.Of(429, "{}")); + + var failure = await Assert.ThrowsAsync<RateLimitedException>( + () => Client.LookupAsync("01001000")); + + Assert.Null(failure.RetryAfter); + } + + [Fact] + public void AnAbsurdRetryAfterIsCappedAtBackoffMax() + { + using var capped = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromMilliseconds(100), + }); + + Assert.Equal(TimeSpan.FromMilliseconds(100), capped.Backoff(1, TimeSpan.FromHours(1))); + } + + [Fact] + public void ARetryAfterUnderTheCapIsHonouredAsAsked() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromSeconds(8), + }); + + Assert.Equal(TimeSpan.FromSeconds(2), client.Backoff(1, TimeSpan.FromSeconds(2))); + } + + [Fact] + public void TheBackoffGrowsExponentiallyAndNeverPassesTheCap() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffBase = TimeSpan.FromMilliseconds(100), + BackoffMax = TimeSpan.FromMilliseconds(800), + }); + + for (var attempt = 1; attempt <= 8; attempt++) + { + var ceiling = TimeSpan.FromMilliseconds(Math.Min(100 * Math.Pow(2, attempt - 1), 800)); + for (var draw = 0; draw < 200; draw++) + { + Assert.InRange(client.Backoff(attempt, null), TimeSpan.Zero, ceiling); + } + } + } + + [Fact] + public void TheBackoffIsJitteredRatherThanTheCeiling() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffBase = TimeSpan.FromSeconds(1), + BackoffMax = TimeSpan.FromSeconds(8), + }); + + var draws = Enumerable.Range(0, 50).Select(_ => client.Backoff(3, null)).Distinct().Count(); + + Assert.True(draws > 1, $"full jitter draws a random wait, got {draws} distinct value(s)"); + } + + [Fact] + public async Task AnAbsurdRetryAfterDoesNotParkTheCaller() + { + using var capped = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromMilliseconds(50), + }); + Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "3600")); + + var failure = await Assert.ThrowsAsync<RateLimitedException>( + () => capped.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); + + Assert.Equal(TimeSpan.FromSeconds(3600), failure.RetryAfter); + Assert.Equal(3, Server.Count("/01001000")); + } + + [Theory] + [InlineData("120", 120)] + [InlineData(" 120 ", 120)] + [InlineData("0", 0)] + public void RetryAfterInSeconds(string header, int expected) => + Assert.Equal(TimeSpan.FromSeconds(expected), CepisClient.ParseRetryAfter(header)); + + [Fact] + public void RetryAfterAsAnHttpDate() + { + var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 2099 07:28:00 GMT"); + + Assert.NotNull(parsed); + Assert.True(parsed > TimeSpan.Zero); + } + + [Fact] + public void AnHttpDateInThePastIsZeroRatherThanNegative() + { + var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 1998 07:28:00 GMT"); + + Assert.Equal(TimeSpan.Zero, parsed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a date at all")] + public void NonsenseRetryAfterIsIgnored(string? header) => + Assert.Null(CepisClient.ParseRetryAfter(header)); + + [Fact] + public async Task NothingListeningIsANetworkError() + { + using var unreachable = new CepisClient(new CepisClientOptions + { + BaseUrl = "http://127.0.0.1:1", + MaxAttempts = 2, + BackoffBase = TimeSpan.FromMilliseconds(1), + BackoffMax = TimeSpan.FromMilliseconds(5), + Timeout = TimeSpan.FromSeconds(30), + }); + + await Assert.ThrowsAsync<NetworkException>(() => unreachable.LookupAsync("01001000")); + } + + [Fact] + public async Task TheTotalTimeoutStopsTheCallShortOfItsAttempts() + { + const int budgetedAttempts = 200; + using var bounded = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromSeconds(30), + MaxAttempts = budgetedAttempts, + BackoffBase = TimeSpan.FromMilliseconds(100), + BackoffMax = TimeSpan.FromMilliseconds(100), + TotalTimeout = TimeSpan.FromSeconds(3), + }); + Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError)); + + var failure = await Assert.ThrowsAsync<ServerErrorException>( + () => bounded.LookupAsync("01001000")); + + var attempts = Server.Count("/01001000"); + Assert.Equal(500, failure.Status); + Assert.InRange(attempts, 1, budgetedAttempts - 1); + } + + [Fact] + public async Task TheTotalTimeoutEndsACallThatNeverGetsAnAnswer() + { + using var bounded = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromMinutes(1), + MaxAttempts = 3, + TotalTimeout = TimeSpan.FromMilliseconds(500), + }); + Server.Handle(_ => + { + Thread.Sleep(TimeSpan.FromSeconds(10)); + return Reply.Of(200, Fixtures.SeAddress); + }); + + await Assert.ThrowsAsync<NetworkException>( + () => bounded.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); + + Assert.Equal(1, Server.Count("/01001000")); + } +} |