namespace Cepis.Tests; /// The retry policy: what is retried, how long it waits, what it never retries. 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( () => 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(() => 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( () => 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( () => 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( () => 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(() => 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( () => 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( () => bounded.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); Assert.Equal(1, Server.Count("/01001000")); } }