Standard Resilience Handler

Add retries, a circuit breaker and per-attempt and total timeouts to an HttpClient with AddStandardResilienceHandler, and tune them safely.

Syntaxbuilder.Services.AddHttpClient<PaymentsClient>(...) .AddStandardResilienceHandler(o => { o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2); o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8); });

The package Microsoft.Extensions.Http.Resilience (10.11.0) puts a Polly pipeline inside an HttpClient handler chain. AddStandardResilienceHandler() on a client builder adds a preconfigured pipeline of five strategies. Order matters: each request passes them outermost first.

The five strategies and their defaults

OrderStrategyDefault
1Rate limiterConcurrency limiter, 1000 permits, queue 0
2Total request timeout30 seconds, covering every attempt and delay
3Retry3 retries, exponential backoff from 2 seconds with jitter, honours Retry-After
4Circuit breakerOpens when 10% of calls fail within 30 seconds, with at least 100 calls; stays open 5 seconds
5Attempt timeout10 seconds per attempt

Retry and the circuit breaker treat as failures: HttpRequestException, a TimeoutRejectedException from the attempt timeout, and responses with status 408, 429 or 500 and above. A 400 or 404 is a correct answer and is never retried. Because the circuit breaker sits inside the retry, each attempt counts towards it; when it is open, attempts fail immediately with BrokenCircuitException instead of waiting for a dead dependency.

Configuring it

Pass a lambda to adjust HttpStandardResilienceOptions, or an IConfigurationSection to bind it from configuration. Options are validated when the client is first created, and two rules catch most mistakes: the total timeout must be greater than the attempt timeout, and the circuit breaker's SamplingDuration must be at least double the attempt timeout. Break either and the first call throws OptionsValidationException.

The defaults suit a background caller with patience. For a dependency called while your own client waits, size the budget from the outside in: if your endpoint must answer in 5 seconds, a 30-second total timeout is meaningless. A typical interactive setting is an attempt timeout of 1-3 seconds, a total of 5-10 seconds and 1-2 retries with a short base delay. HttpClient.Timeout (100 seconds by default) is outside the whole pipeline; keep it larger than the total timeout or it cancels retries early.

Decisions a team makes

  • Unsafe methods. Retries also apply to POST. That is only safe when the downstream API de-duplicates, for example with an Idempotency-Key header. Otherwise call options.Retry.DisableForUnsafeHttpMethods(), or a timed-out payment capture can be charged twice.
  • One breaker per client or per host. The breaker is per named client. If one client calls several hosts, .SelectPipelineByAuthority() gives each authority its own breaker so one bad host does not block the rest.
  • Everywhere or per client. ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler()) covers every client, which Aspire service defaults do. Adding a second handler to one client stacks two pipelines rather than replacing the first.
  • Hedging instead of retry. AddStandardHedgingHandler() sends a second attempt in parallel after a delay instead of waiting for the first to fail, for read-only calls where tail latency matters.

Failure mode: retry amplification

Retries multiply across layers. If the gateway, the orders API and the inventory client each make up to 4 attempts, one user request can become 64 calls to an inventory service that is already overloaded, which keeps it down. Retry at one layer, usually the one closest to the failing dependency, keep attempts low, and let the circuit breaker shed load.

Example

Example · csharp
using Microsoft.Extensions.Http.Resilience;
using Polly.CircuitBreaker;
using Polly.Timeout;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<PaymentGatewayClient>(http =>
{
    http.BaseAddress = new Uri(builder.Configuration["PaymentGateway:BaseUrl"] ?? "https://payments.shop.internal/");
})
.AddStandardResilienceHandler(options =>
{
    // The checkout endpoint promises an answer within 10 seconds.
    options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
    options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);

    options.Retry.MaxRetryAttempts = 2;
    options.Retry.Delay = TimeSpan.FromMilliseconds(250);

    options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
    options.CircuitBreaker.MinimumThroughput = 20;
    options.CircuitBreaker.FailureRatio = 0.2;
    options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
});

var app = builder.Build();

app.MapPost("/orders/{orderId:guid}/payment", async (Guid orderId, CapturePayment body, PaymentGatewayClient gateway, CancellationToken ct) =>
{
    try
    {
        var capture = await gateway.CaptureAsync(orderId, body.AmountMinor, body.Currency, ct);
        return Results.Ok(capture);
    }
    catch (BrokenCircuitException)
    {
        return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable,
            title: "Payments are temporarily unavailable", extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = 15 });
    }
    catch (TimeoutRejectedException)
    {
        return Results.Problem(statusCode: StatusCodes.Status504GatewayTimeout, title: "Payment gateway did not answer in time");
    }
});

app.Run();

public sealed record CapturePayment(long AmountMinor, string Currency);
public sealed record PaymentCapture(string CaptureId, string Status);

public sealed class PaymentGatewayClient(HttpClient http)
{
    public async Task<PaymentCapture> CaptureAsync(Guid orderId, long amountMinor, string currency, CancellationToken ct)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, "v1/captures")
        {
            Content = JsonContent.Create(new { orderId, amountMinor, currency })
        };
        // The same key on every retry lets the gateway de-duplicate, which is what makes retrying a POST safe.
        request.Headers.Add("Idempotency-Key", $"capture-{orderId:N}");

        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<PaymentCapture>(ct))!;
    }
}

When to use it

  • Keep checkout responsive by giving the payment gateway client a 2-second attempt timeout and an 8-second total budget.
  • Stop hammering an inventory service that returns 503s by letting the circuit breaker open for 15 seconds.
  • Retry a payment capture POST safely because every attempt carries the same Idempotency-Key.

More examples

Options bound from configuration

Binding from a section lets operations tighten a timeout per environment without a rebuild. The property names mirror <code>HttpStandardResilienceOptions</code>, and <code>TimeSpan</code> values use the <code>hh:mm:ss</code> format. The same validation runs as with code, so a 20-second attempt timeout with a 30-second sampling window fails on the first call rather than silently disabling the breaker.

Example · json
{
  // builder.Services.AddHttpClient<InventoryClient>(...)
  //     .AddStandardResilienceHandler(builder.Configuration.GetSection("Resilience:Inventory"));
  "Resilience": {
    "Inventory": {
      "TotalRequestTimeout": { "Timeout": "00:00:06" },
      "AttemptTimeout": { "Timeout": "00:00:02" },
      "Retry": {
        "MaxRetryAttempts": 2,
        "Delay": "00:00:00.200",
        "BackoffType": "Exponential",
        "UseJitter": true
      },
      "CircuitBreaker": {
        "FailureRatio": 0.25,
        "MinimumThroughput": 20,
        "SamplingDuration": "00:00:30",
        "BreakDuration": "00:00:10"
      }
    }
  }
}

Resilience for every client, with one exception

Defaults apply to every client, including ones registered by libraries. Adding another <code>AddStandardResilienceHandler</code> to one client would stack a second pipeline inside the first, so retries would multiply. <code>RemoveAllResilienceHandlers()</code> clears the inherited one first; it is marked experimental (diagnostic <code>EXTEXP0001</code>), hence the pragma. Note how the circuit breaker window grows with the attempt timeout to pass validation.

Example · csharp
using Microsoft.Extensions.Http.Resilience;

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());

builder.Services.AddHttpClient("catalog", http => http.BaseAddress = new Uri("https://catalog.shop.internal/"));

// Uploads stream a request body that cannot be replayed, so this client must not retry.
#pragma warning disable EXTEXP0001
builder.Services.AddHttpClient("document-upload", http =>
{
    http.BaseAddress = new Uri("https://documents.shop.internal/");
    http.Timeout = TimeSpan.FromMinutes(5);
})
.RemoveAllResilienceHandlers()
.AddStandardResilienceHandler(options =>
{
    options.Retry.MaxRetryAttempts = 1;
    options.Retry.DisableForUnsafeHttpMethods();
    options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(2);
    options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(4);
    options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(4);
});
#pragma warning restore EXTEXP0001

var app = builder.Build();
app.Run();

One breaker per host for a multi-tenant client

Without <code>SelectPipelineByAuthority()</code> the whole client shares one circuit breaker, so a single merchant with a broken endpoint opens the circuit and blocks deliveries to every other merchant. Keying the pipeline by scheme, host and port isolates them. Keyed on user-controlled hosts it is both an SSRF hole (any caller makes the service POST to <code>169.254.169.254</code> or an internal admin port) and a memory leak, because each new authority gets its own pipeline instance that is kept. So the endpoint requires an operator role, takes only ids, loads the URL the merchant registered, and accepts it only over https to a registered host that resolves to public addresses. A complete defence also checks the connected address in <code>SocketsHttpHandler.ConnectCallback</code>, which closes the DNS rebinding gap.

Example · csharp
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Http.Resilience;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("webhook-operators", policy => policy.RequireRole("support-operator"));
builder.Services.AddSingleton<IWebhookDeliveryStore, InMemoryWebhookDeliveryStore>();

// Each merchant runs its own webhook receiver; the client calls many hosts.
builder.Services.AddHttpClient("merchant-webhooks", http => http.Timeout = TimeSpan.FromSeconds(15))
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 2;
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
    })
    .SelectPipelineByAuthority();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// The caller names a delivery; the URL comes from the merchant's registration, never from the request.
app.MapPost("/internal/webhooks/replay", async (ReplayRequest replay, IWebhookDeliveryStore store,
    IHttpClientFactory factory, CancellationToken ct) =>
{
    var delivery = await store.FindAsync(replay.MerchantId, replay.DeliveryId, ct);
    if (delivery is null)
    {
        return Results.NotFound();
    }
    if (!await CallbackPolicy.IsAllowedAsync(delivery.CallbackUrl, delivery.RegisteredHosts, ct))
    {
        return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Callback URL is not allowed");
    }

    var http = factory.CreateClient("merchant-webhooks");
    using var response = await http.PostAsJsonAsync(delivery.CallbackUrl, delivery.Payload, ct);
    return Results.Ok(new { delivery.CallbackUrl, status = (int)response.StatusCode });
})
.RequireAuthorization("webhook-operators");

app.Run();

static class CallbackPolicy
{
    public static async Task<bool> IsAllowedAsync(Uri url, IReadOnlySet<string> registeredHosts, CancellationToken ct)
    {
        if (url.Scheme != Uri.UriSchemeHttps || !registeredHosts.Contains(url.IdnHost))
        {
            return false;
        }
        var addresses = await Dns.GetHostAddressesAsync(url.IdnHost, ct);
        return addresses.Length > 0 && addresses.All(IsPublic);
    }

    static bool IsPublic(IPAddress ip)
    {
        if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
        if (IPAddress.IsLoopback(ip) || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6UniqueLocal) return false;
        if (ip.AddressFamily != AddressFamily.InterNetwork) return true;
        var b = ip.GetAddressBytes();
        return !(b[0] is 0 or 10 or 127
            || (b[0] == 169 && b[1] == 254)
            || (b[0] == 172 && b[1] is >= 16 and <= 31)
            || (b[0] == 192 && b[1] == 168)
            || (b[0] == 100 && b[1] is >= 64 and <= 127));
    }
}

interface IWebhookDeliveryStore
{
    Task<WebhookDelivery?> FindAsync(Guid merchantId, Guid deliveryId, CancellationToken ct);
}

sealed class InMemoryWebhookDeliveryStore : IWebhookDeliveryStore
{
    public Task<WebhookDelivery?> FindAsync(Guid merchantId, Guid deliveryId, CancellationToken ct) =>
        Task.FromResult<WebhookDelivery?>(null);
}

record ReplayRequest(Guid MerchantId, Guid DeliveryId);
record WebhookDelivery(Uri CallbackUrl, IReadOnlySet<string> RegisteredHosts, OrderShippedEvent Payload);
record OrderShippedEvent(Guid OrderId, string TrackingNumber, DateTimeOffset ShippedAt);

Discussion

  • Be the first to comment on this lesson.