HttpRetryHandler is an opt-in DelegatingHandler for Refit and ordinary HttpClient callers on
.NET 8 and later. It needs no source-generator changes or resilience package.
var handler = new HttpRetryHandler(new HttpRetryOptions
{
MaxRetries = 3,
DelayProvider = RetryDelayProviders.GitHub,
})
{
InnerHandler = new HttpClientHandler(),
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://api.github.com"),
};
var api = RestService.ForGenerated<IGitHubApi>(client);With Refit.HttpClientFactory, add a fresh handler to the existing client registration:
services.AddRefitGeneratedClient<IGitHubApi>()
.ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://api.github.com"))
.AddHttpMessageHandler(static () => new HttpRetryHandler(new HttpRetryOptions
{
DelayProvider = RetryDelayProviders.GitHub,
}));You can also return a complete handler chain from RefitSettings.HttpMessageHandlerFactory.
An authentication handler placed inside the retry handler runs for each attempt, so it can
refresh credentials. A handler placed outside runs once per logical call.
- Three extra attempts after the initial send.
MaxRetries = 0bypasses retries and buffering. - Retry HTTP 408, 429, 500, 502, 503 and 504, plus
HttpRequestException. - A 403 retries only when the configured delay reader returns a valid retry hint. An ordinary authorization failure does not retry. A successful response never retries by default.
- GET, HEAD, OPTIONS and TRACE may retry. Other methods require
RetryUnsafeMethods = true. Opt in only when replay is safe for the endpoint, for example with a server-supported idempotency key. Cancellation and exceptions other thanHttpRequestExceptionnever retry. - Missing or malformed hints use exponential backoff starting at one second, capped at
30 seconds, with full jitter. Disable jitter with
UseJitter = false. - Valid server hints replace fallback backoff and are not shortened by jitter. A negative
custom interval or a hint above
MaxDelaystops retries and returns the response. - Each attempt gets a fresh request, copied headers, HTTP version, options and content headers. Bodies are serialized once into a bounded buffer, one MiB by default. Oversized bodies fail before sending. Retries are not a suitable choice for unbounded streaming uploads.
- Failed responses and cloned requests are disposed by the handler. The final response and original request remain caller-owned. Response content is not buffered or inspected.
Options are copied when constructing the handler. Custom callbacks must be thread-safe.
TimeProvider supplies both the clock for absolute hints and the timers for backoff.
Cancellation covers buffering, sends and waits. HttpClient.Timeout still bounds the logical
call, including retry waits.
| Reader | Headers, in priority order |
|---|---|
RetryAfter (default) |
Standard Retry-After, integer seconds or HTTP date. |
Azure |
x-ms-retry-after-ms, retry-after-ms in milliseconds, then Retry-After. |
Aws |
x-amz-retry-after in milliseconds, then Retry-After. |
GitHub |
Retry-After; otherwise x-ratelimit-reset in Unix seconds when status is 403/429 and x-ratelimit-remaining is zero. |
Jira |
Retry-After; otherwise X-RateLimit-Reset as ISO 8601 on HTTP 429. |
Microsoft Graph and Slack use standard Retry-After, so the default reader covers them.
Readers reject ambiguous multiple values, malformed numbers and overflow. Past absolute
timestamps map to zero. An invalid higher-priority header falls through to the next reader.
The AWS reader supports documented headers, not the AWS SDK's complete retry policy. Providers can also put service-specific hints inside JSON bodies. Microsoft Graph batches can report throttled operations inside an HTTP 200 response. Those cases need application-level handling, not replay of the whole HTTP response. A GitHub secondary limit with no timing headers cannot be distinguished from an ordinary 403 by a header-only adapter.
var options = new HttpRetryOptions
{
DelayProvider = static (response, now, token) =>
RetryDelayProviders.ReadHeader(
response, "X-Retry-Milliseconds", RetryHeaderFormat.Milliseconds, now, token)
?? RetryDelayProviders.RetryAfter(response, now, token),
ShouldRetry = static response =>
response.StatusCode is HttpStatusCode.TooManyRequests
or HttpStatusCode.ServiceUnavailable,
};RetryHeaderFormat supports seconds, milliseconds, Unix seconds, standard Retry-After
and ISO 8601 dates. ShouldRetry replaces default response classification.
DelayProvider = null disables header hints. RetryHttpRequestExceptions = false disables
transport retries without affecting response retries.
Refit.Testing supplies RetryReply.After for seconds or dates and RetryReply.Header
for provider-specific or deliberately invalid hints. Each match produces a fresh response.
StubHttp.CreateRetryHandler() uses the stub's clock. The overload taking explicit options
uses their clock instead.
var clock = new FakeTimeProvider();
using var stub = new StubHttp { TimeProvider = clock };
stub.Add(Route.Get("/users/{id}"), RetryReply.After(TimeSpan.FromSeconds(5)));
stub.Add(Route.Get("/users/{id}"), Reply.With(new User(7, "octocat")));
using var client = new HttpClient(stub.CreateRetryHandler())
{
BaseAddress = new Uri("https://api.test"),
};
var api = RestService.ForGenerated<IUserApi>(client);
var pending = api.GetUser(7);
clock.Advance(TimeSpan.FromSeconds(5));
var user = await pending;
await stub.VerifyAllCalledAsync();FakeTimeProvider comes from Microsoft.Extensions.TimeProvider.Testing. Advance time only
after the relevant send has reached its wait. See HttpRetryHandlerTests.Time.cs for an
observable timer-registration pattern for multi-attempt tests.
The handler boundary also fits timeouts, concurrency limits and circuit breakers without changing generated clients. Those strategies are not built into this retry handler. Place per-attempt timeouts or concurrency limits inside retries. Place a total-call timeout outside. A circuit breaker's position determines whether it counts individual attempts or final logical failures. Partition rate limits and breakers by the relevant provider, tenant or endpoint rather than pausing unrelated requests.
Both Microsoft.Extensions.Resilience and Microsoft.Extensions.Http.Resilience are built
on Polly. They remain optional integrations; this handler does not depend on either.
Do not stack two retry strategies without calculating their combined attempt count.
For example, three retries in each of two layers can cause sixteen network sends.
Hedging runs parallel attempts and needs stricter replay safeguards. A typed fallback chooses replacement application data, so it belongs above the HTTP handler.
Use [Query][AliasAs("client_id")] string clientId for a scalar query alias.
[Query("client_id")] sets a nesting delimiter, not the query name.
For a relative redirect Location, resolve it against the request URL with
new Uri(requestUrl, location), then pass that absolute URI to an existing [Url] parameter
on a method with an empty path. The encoded query stays intact. A route placeholder treats
? and = as path data and escapes them. No new attribute or generator hook is needed.