Description
The default AddStandardResilienceHandler pipeline honors an upstream Retry-After response header when scheduling retries. However, the retry delay is not constrained by the remaining TotalRequestTimeout budget or the TotalRequestTimeout value.
For example, with the default 30-second total request timeout, an upstream response containing Retry-After: 60 will inevitably cause the pipeline to wait before the outer total request timeout aborts the operation. The retry cannot possibly execute, so this wait is unnecessary and delays failure.
Expected behavior: by default, retry should make reasonable attempt to avoid scheduling a retry when it cannot possibly succeed (e.g. when delay from Retry-After exceeds the remaining budget, or at least when it exceeds the total request timeout)
Reproduction Steps
The following code throws TimeoutRejectedException after waiting pointlessly for 30 seconds.
const string ClientName = "resilience-probe";
const string RequestUri = "https://stub.invalid/resource";
// Change these values to simulate another upstream response.
var stubResponse = new StubResponse(
HttpStatusCode.TooManyRequests,
"Simulated upstream failure.",
TimeSpan.FromSeconds(100));
var attemptCount = 0;
var services = new ServiceCollection();
services
.AddHttpClient(ClientName)
.ConfigurePrimaryHttpMessageHandler(() => new StubHttpMessageHandler(
stubResponse,
() => attemptCount++))
.AddStandardResilienceHandler();
using var serviceProvider = services.BuildServiceProvider();
var client = serviceProvider
.GetRequiredService<IHttpClientFactory>()
.CreateClient(ClientName);
using var response = await client.GetAsync(RequestUri);
Console.WriteLine($"Final response: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($"Stub attempts: {attemptCount}");
Console.WriteLine(await response.Content.ReadAsStringAsync());
sealed record StubResponse(
HttpStatusCode StatusCode,
string Content,
TimeSpan? RetryAfter);
sealed class StubHttpMessageHandler(
StubResponse response,
Action recordAttempt) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
recordAttempt();
var message = new HttpResponseMessage(response.StatusCode)
{
Content = new StringContent(response.Content),
RequestMessage = request
};
if (response.RetryAfter is { } retryAfter)
{
message.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter);
}
return Task.FromResult(message);
}
}
Expected behavior
The above code should fail immediately.
Actual behavior
The above code throws TimeoutRejectedException after waiting pointlessly for 30 seconds.
Regression?
No response
Known Workarounds
No response
Configuration
No response
Other information
No response
Description
The default
AddStandardResilienceHandlerpipeline honors an upstreamRetry-Afterresponse header when scheduling retries. However, the retry delay is not constrained by the remainingTotalRequestTimeoutbudget or theTotalRequestTimeoutvalue.For example, with the default 30-second total request timeout, an upstream response containing
Retry-After: 60will inevitably cause the pipeline to wait before the outer total request timeout aborts the operation. The retry cannot possibly execute, so this wait is unnecessary and delays failure.Expected behavior: by default, retry should make reasonable attempt to avoid scheduling a retry when it cannot possibly succeed (e.g. when delay from
Retry-Afterexceeds the remaining budget, or at least when it exceeds the total request timeout)Reproduction Steps
The following code throws
TimeoutRejectedExceptionafter waiting pointlessly for 30 seconds.Expected behavior
The above code should fail immediately.
Actual behavior
The above code throws
TimeoutRejectedExceptionafter waiting pointlessly for 30 seconds.Regression?
No response
Known Workarounds
No response
Configuration
No response
Other information
No response