Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions Utilities/HttpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,20 +163,27 @@ private static bool IsTransientFailure(Outcome<HttpResponseMessage> outcome)
if (outcome.Exception is null)
{
return outcome.Result is not null
&& (int)outcome.Result.StatusCode is 408 or 429 or >= 500;
&& IsTransientStatusCode((int)outcome.Result.StatusCode);
}

// Retry only known-transient failures: a timeout (a cancellation with an inner
// TimeoutException) or a network/IO error. Caller cancellation, an open circuit, and any
// other exception (including programming errors) are not retried.
// Retry known-transient failures: a request timeout (a cancellation with an inner
// TimeoutException), a network or IO error (an HttpRequestException with no status, or an
// IOException), or an HttpRequestException whose own status code is transient (408, 429,
// >= 500). Caller cancellation, an open circuit, a 4xx status, and any other exception
// (including programming errors) are not retried.
return outcome.Exception switch
{
OperationCanceledException canceled => canceled.InnerException is TimeoutException,
HttpRequestException or IOException => true,
HttpRequestException { StatusCode: null } or IOException => true,
HttpRequestException { StatusCode: { } statusCode } => IsTransientStatusCode(
(int)statusCode
),
_ => false,
};
}

private static bool IsTransientStatusCode(int statusCode) => statusCode is 408 or 429 or >= 500;

private static string FormatOutcome(Outcome<HttpResponseMessage> outcome) =>
outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString() ?? "unknown";
}
35 changes: 35 additions & 0 deletions UtilitiesTests/HttpClientFactoryResilienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,41 @@ public async Task NonTransientException_IsNotRetried()
_ = stub.CallCount.Should().Be(1); // programming errors are not retried
}

[Fact]
public async Task HttpRequestExceptionWithNonTransientStatus_IsNotRetried()
{
using StubHttpMessageHandler stub = new(
Throws(new HttpRequestException("not found", null, HttpStatusCode.NotFound))
);
using HttpClient client = CreateStubbedClient(stub, FastOptions());

_ = await FluentActions
.Awaiting(() => client.GetAsync(new Uri("http://localhost/")))
.Should()
.ThrowAsync<HttpRequestException>();
_ = stub.CallCount.Should().Be(1); // a 404 HttpRequestException is not transient
}

[Fact]
public async Task HttpRequestExceptionWithTransientStatus_IsRetriedThenSucceeds()
{
using StubHttpMessageHandler stub = new(
Throws(
new HttpRequestException("unavailable", null, HttpStatusCode.ServiceUnavailable)
),
Throws(
new HttpRequestException("unavailable", null, HttpStatusCode.ServiceUnavailable)
),
Status(HttpStatusCode.OK)
);
using HttpClient client = CreateStubbedClient(stub, FastOptions());

using HttpResponseMessage response = await client.GetAsync(new Uri("http://localhost/"));

_ = response.StatusCode.Should().Be(HttpStatusCode.OK);
_ = stub.CallCount.Should().Be(3); // 503 exception retried, then success
}

private static HttpClientOptions FastOptions() =>
new()
{
Expand Down