Skip to content

HTTP-aware retries

When a service is busy, it may tell you when to try again. HttpRetryHandler reads that response header and waits before sending a fresh request. Without a valid hint, it uses exponential backoff: each wait grows until it reaches a configured limit.

The handler is opt-in and works with both generated Refit clients and ordinary HttpClient. It lives in Refit on .NET 8 and later. It needs no interface attributes, generator changes or resilience package.

Make a retrying client

1. Describe a read. This method returns the HTTP response itself, so it needs no JSON configuration. Dispose the response after reading it.

internal interface IStatusApi
{
    [Get("/status")]
    Task<HttpResponseMessage> GetStatusAsync(CancellationToken cancellationToken);
}

2. Choose the policy and transport. MaxRetries counts extra sends after the first. Choose the delay reader that matches your provider.

var handler = new HttpRetryHandler(new HttpRetryOptions
{
    MaxRetries = 3,
    DelayProvider = RetryDelayProviders.Azure,
})
{
    InnerHandler = new HttpClientHandler(),
};
using var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://api.example"),
};

3. Create and call the generated client. The same cancellation token stops sends and retry waits.

var api = RestService.ForGenerated<IStatusApi>(client);
using var response = await api.GetStatusAsync(cancellationToken);

For dependency injection, add a fresh handler to the client registration:

services.AddRefitGeneratedClient<IStatusApi>()
    .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://api.example"))
    .AddHttpMessageHandler(static () => new HttpRetryHandler(new HttpRetryOptions
    {
        DelayProvider = RetryDelayProviders.Azure,
    }));

You can also supply the complete chain through RefitSettings.HttpMessageHandlerFactory. See client creation and dependency injection.

Choose which failures retry

By default, the handler retries HTTP 408, 429, 500, 502, 503 and 504, plus HttpRequestException. HTTP 403 retries only when the delay reader finds a valid timing hint. An ordinary authorization failure does not retry. Successful responses do not retry.

ShouldRetry replaces response classification. Transport failures have their own switch:

var options = new HttpRetryOptions
{
    ShouldRetry = static response =>
        response.StatusCode is HttpStatusCode.TooManyRequests
            or HttpStatusCode.ServiceUnavailable,
    RetryHttpRequestExceptions = false,
};

GET, HEAD, OPTIONS and TRACE may retry by default. Set RetryUnsafeMethods = true only when replay is safe for the endpoint. For a create operation, that usually means a server-supported idempotency key: a key that lets the server recognize repeated sends of the same operation. Cancellation and exceptions other than HttpRequestException never retry.

MaxRetries = 0 bypasses both retries and body buffering.

Read provider timing hints

Readers try these headers in priority order:

ReaderHeaders and units
RetryAfter (default)Retry-After: integer seconds or an HTTP date.
Azurex-ms-retry-after-ms, then retry-after-ms: milliseconds; then Retry-After.
Awsx-amz-retry-after: milliseconds; then Retry-After.
GitHubRetry-After; otherwise x-ratelimit-reset in Unix seconds on 403/429 when x-ratelimit-remaining is zero.
JiraRetry-After; otherwise X-RateLimit-Reset as ISO 8601 on 429.

Microsoft Graph and Slack use standard Retry-After, so the default reader covers them. The AWS reader reads timing headers, not the AWS SDK's full retry policy.

Readers reject ambiguous multiple values, malformed numbers and overflow. An invalid higher-priority header falls through to the next header. A past absolute timestamp means the retry may start immediately.

For a custom header, choose its encoding:

var options = new HttpRetryOptions
{
    DelayProvider = static (response, now, token) =>
        RetryDelayProviders.ReadHeader(
            response, "X-Retry-Milliseconds", RetryHeaderFormat.Milliseconds, now, token)
        ?? RetryDelayProviders.RetryAfter(response, now, token),
};

RetryHeaderFormat supports seconds, milliseconds, Unix seconds, standard Retry-After and ISO 8601 dates. DelayProvider = null disables header hints.

Backoff, jitter and time

The fallback starts at one second and doubles up to 30 seconds. Full jitter randomizes the fallback wait between zero and that attempt's cap. Set UseJitter = false for a fixed wait. BaseDelay and MaxDelay change the limits.

Valid server hints replace fallback backoff. Jitter never shortens a server hint. A negative custom interval or a hint above MaxDelay stops retries and returns the response. Increase MaxDelay if you want to wait through a longer provider rate-limit window.

TimeProvider supplies both the clock for dates and the timers for waits. HttpClient.Timeout bounds the whole logical call, including those waits.

Options are copied when the handler is created. Custom callbacks must be thread-safe because calls can share a handler.

Request replay and ownership

Each send gets a fresh request with the original headers, HTTP version, options and content headers. Bodies are serialized once into a bounded buffer. The default limit is one MiB, set through MaxRequestContentBufferSize. Oversized bodies fail before the first send. Do not use retries for unbounded streaming uploads.

The handler disposes failed responses and cloned requests. The caller owns the original request and final response. The handler does not read or buffer response content.

A failed JSON read happens after the HTTP handler has returned. Retrying at this layer does not cover it. A Microsoft Graph batch can report throttled operations inside an HTTP 200 response; handle those operations in application code rather than replaying the batch. A GitHub secondary limit without timing headers cannot be distinguished from an ordinary 403 by a header-only reader.

Test retries without a network

Refit.Testing provides RetryReply.After for seconds or dates and RetryReply.Header for provider-specific or deliberately invalid hints. Each match creates a fresh response. Queue one-shot routes with the same matcher to supply a failure followed by success:

using var stub = new StubHttp();
stub.Add(Route.Get("/status"), RetryReply.After(TimeSpan.Zero));
stub.Add(Route.Get("/status"), Reply.Status(HttpStatusCode.OK));
using var client = new HttpClient(stub.CreateRetryHandler())
{
    BaseAddress = new Uri("https://api.test"),
};
var api = RestService.ForGenerated<IStatusApi>(client);
using var response = await api.GetStatusAsync(cancellationToken);
await stub.VerifyAllCalledAsync();

StubHttp.CreateRetryHandler() uses the stub's TimeProvider. The overload taking options uses their clock instead. To test nonzero waits, give the stub a FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing and advance it after a retry timer is registered. For asynchronous responders and multiple retries, observe timer registration rather than guessing when to advance. The retry timing tests show that pattern.

See testing for route matching and verification, and testing streams and time for the stub clock.

Compose other resilience features

A resilience strategy changes how a call handles failures or load. The handler boundary also fits timeouts, concurrency limits and circuit breakers. They are not built into HttpRetryHandler.

  • Put an attempt timeout or concurrency limit inside retries so it applies to each send.
  • Put a total-call timeout outside retries so it covers sends and waits together.
  • Put a circuit breaker inside retries to count attempts, or outside to count logical failures. A circuit breaker stops calls temporarily after repeated failures.
  • Partition rate limits and breakers by provider, tenant or endpoint so one busy service does not pause unrelated requests.

Hedging sends parallel attempts and needs stricter replay safeguards. A typed fallback chooses replacement application data, so it belongs above the HTTP layer.

Both Microsoft.Extensions.Resilience and Microsoft.Extensions.Http.Resilience are built on Polly. They remain optional. See Microsoft resilience integration. Do not stack retry strategies without calculating their combined attempts. Three retries in each of two layers can produce sixteen network sends.

Provider references