dotnet / dotnet/extensions

Standard HTTP resilience pipeline waits for Retry-After beyond total request timeout

Open
#7,701 0 comments 0 reactions 0 assignees View on GitHub
bug untriaged
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.