Skip to content

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

Description

@scharnyw

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis issue describes a behavior which is not expected - a bug.untriaged

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions