summaryrefslogtreecommitdiffstats
path: root/tests/Cepis.Tests/RetryTests.cs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Cepis.Tests/RetryTests.cs')
-rw-r--r--tests/Cepis.Tests/RetryTests.cs249
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"));
+ }
+}