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"));
}
}