Standard HTTP resilience pipeline waits for Retry-After beyond total request timeout
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 894
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 23
Description
### 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.
```csharp
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()
.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 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_
Contributor guide
Research direction
Start with the supplied AddStandardResilienceHandler reproduction and trace how Retry-After interacts with TotalRequestTimeout. Verify the behavior with the 30-second timeout and a 60-second Retry-After value; done means the request fails immediately instead of waiting for an impossible retry.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100