Every call your .NET service makes over a network can fail transiently: a database fails over, a downstream API throttles you, a pod restarts mid-request. Resilience in .NET means handling those failures deliberately, with retries, circuit breakers and timeouts you chose on purpose, instead of an unhandled exception or, worse, a retry loop that turns one slow dependency into an outage for everyone. This guide covers Polly v8, the resilience engine underneath almost every .NET HTTP client today, and Microsoft.Extensions.Http.Resilience, the opinionated, DI-friendly package built on top of it, including retries, circuit breakers, timeouts, hedging, rate limiting, fallback, telemetry, chaos testing with Simmy, and the anti-patterns that turn resilience code into the outage it was meant to prevent.

What Transient Faults Are and Why .NET Needs a Resilience Library#

A transient fault is a failure that is likely to succeed if you simply try again after a short wait: a dropped connection, a 503 from an overloaded service, a database deadlock, a DNS blip. It is different from a permanent failure, such as a 404 for a resource that does not exist or a 400 for a malformed request, where retrying only wastes time and load. Handling transient faults well means three things at once: retrying the failures worth retrying, giving up quickly on the ones that are not, and protecting a struggling dependency from a thundering herd of retries piling on right when it is least able to handle them.

Writing this by hand, correctly, across dozens of call sites is not realistic. Polly exists so you express a policy once (retry three times with backoff, break the circuit after a run of failures, cap the attempt at two seconds) and apply it consistently, instead of reinventing try/catch/Task.Delay loops with slightly different bugs in every service.

How Polly v8 and Microsoft.Extensions.Http.Resilience Work Together#

Polly 8 (the current major version, 8.8.0 as of September 2026) is built around ResiliencePipeline, a compiled, allocation-conscious chain of resilience strategies you build with ResiliencePipelineBuilder. Polly organizes strategies into two families: reactive strategies that respond to an exception or a failed result (retry, circuit breaker, fallback, hedging), and proactive strategies that guard against a call happening at all under bad conditions (timeout, rate limiter). Polly is transport-agnostic: nothing about a ResiliencePipeline knows about HttpClient specifically, so the same engine wraps HTTP calls, database calls, message handlers or any other operation you can express as a delegate.

Microsoft.Extensions.Http.Resilience (currently 10.10.0, built on Polly and targeting .NET 8 and later) is Microsoft's opinionated layer on top of that engine, specifically for IHttpClientFactory. It adds extension methods on IHttpClientBuilder that register a pre-built, production-shaped ResiliencePipeline as a DelegatingHandler, wires it into DI with IOptions<T>-style configuration, and connects its telemetry to ILogger and System.Diagnostics.Metrics automatically. You reach for Polly directly when you need a bespoke pipeline or you are resiliencing something other than HTTP; you reach for Microsoft.Extensions.Http.Resilience first for ordinary outbound HTTP calls, because it saves you from re-deriving sensible defaults.

Getting Started: One Line of Resilience for IHttpClientFactory#

AddStandardResilienceHandler is the fastest path from an unprotected HttpClient to a production-shaped one:

C#
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<InventoryClient>(client =>
        client.BaseAddress = new Uri(builder.Configuration["Inventory:BaseUrl"]!))
    .AddStandardResilienceHandler();

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

public sealed class InventoryClient(HttpClient http)
{
    public Task<StockLevel?> GetStockAsync(string sku, CancellationToken ct) =>
        http.GetFromJsonAsync<StockLevel>($"/api/stock/{Uri.EscapeDataString(sku)}", ct);
}

public sealed record StockLevel(string Sku, int Available);

That one call adds a full pipeline: rate limiting, an overall timeout, retries with backoff, a circuit breaker and a per-attempt timeout, all pre-configured. You override individual settings with Configure, which returns the same builder so it composes with the rest of your AddHttpClient chain:

C#
builder.Services.AddHttpClient<InventoryClient>(client =>
        client.BaseAddress = new Uri(builder.Configuration["Inventory:BaseUrl"]!))
    .AddStandardResilienceHandler()
    .Configure(options =>
    {
        options.CircuitBreaker.MinimumThroughput = 10;
        options.Retry.MaxRetryAttempts = 4;
    });

Inside AddStandardResilienceHandler: The Five-Layer Pipeline#

The standard handler is not one strategy; it is five, composed outer to inner: a rate limiter that caps total concurrent requests through the handler, a total request timeout bounding the whole operation including every retry, retry, a circuit breaker, and a per-attempt timeout as the innermost layer, defaulting to 10 seconds. That ordering is deliberate: the rate limiter rejects excess load before any work starts, the total timeout guarantees a caller is never stuck indefinitely no matter how many retries fire, and the attempt timeout stops one slow call from consuming the whole retry budget by itself. Each layer is independently configurable through HttpStandardResilienceOptions, so you can, for example, disable the rate limiter for an internal client while keeping the rest.

AddStandardHedgingHandler is a variant built for fan-out scenarios: instead of one circuit breaker for the client, it keeps a pool of circuit breakers, selected by the target URL's authority (scheme, host and port) by default, so an unhealthy replica is excluded from hedging without penalizing healthy ones behind the same client.

Retry with Exponential Backoff and Jitter#

Raw Polly retry configuration is where the exponential-backoff-plus-jitter pattern lives underneath both Microsoft.Extensions.Http.Resilience and the guide's earlier SQL Server examples. ShouldHandle decides what counts as retryable, and jitter spreads out retries from many clients so they do not all land on the dependency at the same instant:

C#
using Polly;
using Polly.Retry;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(response => (int)response.StatusCode is >= 500 or 429),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        MaxRetryAttempts = 4,
        Delay = TimeSpan.FromSeconds(1),
        OnRetry = args =>
        {
            Console.WriteLine($"Retry {args.AttemptNumber} after {args.RetryDelay}");
            return default;
        },
    })
    .Build();

Retrying on 429 Too Many Requests only helps if you also respect a Retry-After header when the dependency sends one; blind exponential backoff on a rate-limited endpoint can still arrive faster than the server wants. For calls that are not naturally idempotent, pair retry with an idempotency key rather than relying on backoff alone, covered in the anti-patterns section below.

Circuit Breakers: Stop Hammering a Failing Dependency#

A circuit breaker protects the dependency, not just the caller: once failures cross a threshold, it stops sending requests for a cooldown period instead of letting every caller keep retrying into an already-struggling service.

C#
using Polly.CircuitBreaker;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio = 0.5,
        SamplingDuration = TimeSpan.FromSeconds(30),
        MinimumThroughput = 20,
        BreakDuration = TimeSpan.FromSeconds(15),
        OnOpened = args =>
        {
            logger.LogWarning("Circuit opened for {BreakDuration}", args.BreakDuration);
            return default;
        },
        OnClosed = _ => { logger.LogInformation("Circuit closed"); return default; },
    })
    .Build();

MinimumThroughput matters as much as FailureRatio: with a minimum of 20, the breaker ignores the failure ratio entirely until at least 20 calls have happened inside the sampling window, so a low-traffic client cannot trip the breaker on two bad calls out of two. After BreakDuration elapses, the circuit moves to half-open and lets a limited number of probe calls through before fully closing again or reopening.

Timeouts: Attempt Timeout vs Total Request Timeout#

Two different timeouts answer two different questions, and conflating them is a common source of confusing latency bugs. An attempt timeout bounds a single try, so one slow call fails fast and frees the retry strategy to try again. A total request timeout bounds the entire operation, including every retry and the delay between them, so a caller has an upper bound on end-to-end latency regardless of how the retry policy is configured.

C#
using Polly.Timeout;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddTimeout(TimeSpan.FromSeconds(20))   // total request timeout, outermost
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage> { MaxRetryAttempts = 3 })
    .AddTimeout(TimeSpan.FromSeconds(5))    // attempt timeout, innermost
    .Build();

Set the attempt timeout well below the total timeout, or every attempt after the first will time out before it can retry again inside the remaining budget. TimeoutRejectedException is what a timed-out attempt throws; make sure ShouldHandle on the surrounding retry strategy includes it if you want timeouts retried.

Hedging: Trading Extra Requests for Lower Tail Latency#

Hedging fights tail latency rather than outright failure: if the primary attempt has not returned within a delay, it starts one or more additional attempts and takes whichever finishes first. It only makes sense against idempotent, side-effect-free operations, since more than one attempt might actually reach the server.

C#
using Polly.Hedging;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddHedging(new HedgingStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .HandleResult(r => !r.IsSuccessStatusCode),
        MaxHedgedAttempts = 2,
        Delay = TimeSpan.FromMilliseconds(300),
        ActionGenerator = args => () => args.Callback(args.ActionContext),
    })
    .Build();

AddStandardHedgingHandler in Microsoft.Extensions.Http.Resilience packages this pattern for HttpClient directly, combined with its per-endpoint circuit breaker pool, so a hedge attempt is not sent to a replica the pool already knows is unhealthy.

Rate Limiting Outbound Calls#

Polly's rate limiter strategy wraps System.Threading.RateLimiting, the general-purpose rate-limiting API in the base class libraries, so the same limiter types you use for inbound ASP.NET Core rate limiting apply to outbound calls too:

C#
using Polly.RateLimiting;
using System.Threading.RateLimiting;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRateLimiter(new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
    {
        PermitLimit = 100,
        Window = TimeSpan.FromMinutes(1),
        SegmentsPerWindow = 4,
    }))
    .Build();

Use this to protect a downstream dependency that has its own published rate limit, or to cap how much load your own outbound calls can put on a shared connection pool, independent of any rate limiting the dependency itself enforces.

Fallback: Graceful Degradation#

Fallback provides a usable response when every other strategy has given up, which is often better for the caller than propagating an exception:

C#
using Polly.Fallback;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<BrokenCircuitException>()
            .Handle<TimeoutRejectedException>(),
        FallbackAction = _ => Outcome.FromResultAsValueTask(
            new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = JsonContent.Create(CachedRecommendations.Default),
            }),
    })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>())
    .Build();

Good fallback values are cached or default data, a degraded feature flag, or a queued-for-later response, never a silent empty success that hides a real outage from monitoring.

Custom Pipelines with AddResilienceHandler#

AddStandardResilienceHandler covers most outbound HTTP calls, but AddResilienceHandler gives you the same DI-friendly registration with a hand-composed pipeline when the standard shape does not fit:

C#
builder.Services.AddHttpClient<PaymentsClient>()
    .AddResilienceHandler("payments-pipeline", pipelineBuilder =>
    {
        pipelineBuilder
            .AddConcurrencyLimiter(permitLimit: 50)
            .AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 2 })
            .AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions())
            .AddTimeout(TimeSpan.FromSeconds(8));
    });

Reach for this when a client needs a strategy the standard handler does not include, such as hedging or a custom rate limiter, or when different endpoints on the same base client genuinely need different policies.

Telemetry: Seeing What Your Resilience Pipeline Is Doing#

A resilience pipeline that fails silently is barely better than no pipeline at all. Polly v8 emits structured telemetry for every strategy event (a retry attempt, a circuit opening, a timeout) through System.Diagnostics.Metrics and, when you attach a logger, through ILogger. Raw ResiliencePipelineBuilder usage wires this up with ConfigureTelemetry; Microsoft.Extensions.Http.Resilience does it automatically for every handler registered through DI, tagged with the pipeline name and the named or typed HttpClient it belongs to. Feed those metrics into OpenTelemetry alongside your regular tracing so a retry storm or an open circuit shows up on the same dashboards as everything else, instead of only in scattered log lines.

Chaos Engineering with Simmy#

Simmy, integrated directly into Polly since version 8.3.0, injects synthetic faults into a pipeline so you can verify your resilience strategies actually work before production finds the gap for you. Chaos strategies are ordinary pipeline strategies, usually added last so they sit closest to the real call and exercise every layer above them:

C#
using Polly.Simmy;
using Polly.Simmy.Fault;
using Polly.Simmy.Latency;

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage> { MaxRetryAttempts = 3 })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>())
    .AddTimeout(TimeSpan.FromSeconds(5))
    .AddChaosFault(0.02, () => new HttpRequestException("Injected by chaos testing"))
    .AddChaosLatency(0.10, TimeSpan.FromSeconds(3))
    .Build();

AddChaosFault throws a synthetic exception for a fraction of calls (2% here), AddChaosLatency injects extra delay, and Simmy also offers outcome injection (returning a fabricated result, such as a 500 response) and behavior injection (running arbitrary code, such as restarting a dependency mid-test) for more elaborate game-day exercises. Gate chaos strategies behind configuration so they are enabled only in controlled environments, never unconditionally in production code paths.

Anti-Patterns: Retry Storms and Retrying Non-Idempotent Calls#

Two mistakes cause more outages than the ones resilience code is meant to prevent.

A retry storm happens when many callers retry the same failing dependency at once, in lockstep, and each retry adds to the load that caused the failures in the first place. It gets worse when retries stack across layers: an API gateway retries, the service it calls retries again, and a message broker redelivers on top of both, turning one failure into ten or more attempts. Jitter spreads out retries in time; a circuit breaker stops sending them once the dependency is clearly unhealthy; and picking exactly one layer in the call path to own retries for a given hop, documented so the next engineer does not add a second one, prevents the multiplication entirely.

Retrying a non-idempotent operation (charging a card, sending an email, incrementing a counter) risks applying it more than once: the first attempt may have succeeded on the server before the response was lost, and a naive retry repeats the side effect. Make the operation idempotent before you make it retryable, typically with an idempotency key the server deduplicates on:

C#
public async Task<PaymentResult> ChargeAsync(
    PaymentRequest request, ResiliencePipeline<PaymentResult> pipeline, CancellationToken ct)
{
    // Stable per logical charge, not per HTTP attempt, so retries dedupe server-side
    var idempotencyKey = $"charge-{request.OrderId}";

    return await pipeline.ExecuteAsync(async token =>
    {
        using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/charges")
        {
            Content = JsonContent.Create(request),
        };
        httpRequest.Headers.Add("Idempotency-Key", idempotencyKey);

        var response = await http.SendAsync(httpRequest, token);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<PaymentResult>(token)
            ?? throw new InvalidOperationException("Empty payment response.");
    }, ct);
}

Polly Resilience Pipelines vs Dapr Resiliency Policies#

If your services already run behind Dapr, you have a second place resilience could live: Dapr's declarative resiliency YAML, applied at the sidecar.

ConcernPolly / Microsoft.Extensions.Http.ResilienceDapr resiliency policies
Where it runsIn-process, inside your .NET codeIn the sidecar, outside your process
ConfigurationC# code and IOptionsYAML, per target app, actor type or component
Strategy varietyRetry, circuit breaker, timeout, hedging, rate limiter, fallback, chaosTimeout, retry, circuit breaker
Applies toAnything you wrap in a pipeline: HTTP, DB, messagingDapr building-block calls only (invocation, pub/sub, bindings)
Best fit.NET-only call paths, or strategies Dapr does not offerPolyglot systems standardizing resiliency outside application code

Pick one layer per call path and document it. Running a Dapr retry policy and an in-process Polly retry on the same service invocation multiplies attempts the same way stacked retries do anywhere else, and the fix is the same: decide who owns retries for that hop and disable the other.

Best Practices#

  • Start with AddStandardResilienceHandler for outbound HTTP and override only the settings your dependency's behavior actually requires.
  • Always use jitter with exponential backoff. Without it, synchronized clients retry in lockstep and recreate the load spike that caused the failures.
  • Set the attempt timeout well below the total request timeout, so a slow attempt still leaves room for at least one retry.
  • Tie MinimumThroughput to real traffic volume. A circuit breaker with too low a minimum trips on noise; too high, and it never protects a low-traffic dependency.
  • Reserve hedging for idempotent, read-heavy calls where a duplicate in-flight request is harmless.
  • Wire telemetry into your existing observability stack so retries and open circuits are visible next to traces and logs, not only in isolated counters.
  • Run chaos tests with Simmy in a non-production environment before trusting a pipeline's behavior under real failures.

Common Pitfalls#

  • Retrying non-idempotent operations without an idempotency key. A lost response plus a retry can double-charge a customer or send a duplicate notification.
  • Stacking retries across layers. A gateway, a service and a broker each retrying the same failure multiplies load exactly when the dependency can least afford it.
  • Treating every exception as retryable. A 400 Bad Request will fail the same way on every attempt; only retry faults that are plausibly transient.
  • Circuit breakers with no MinimumThroughput floor. Two failed calls out of two should not open a circuit that serves thousands of requests a day.
  • Hedging non-idempotent calls. Two in-flight attempts at a write operation can both succeed, applying it twice.
  • No telemetry on the pipeline. Without visibility into retries and circuit state, a resilience pipeline can mask a real outage as merely "a bit slower today."

Frequently Asked Questions#

Do I still need Polly if I use Microsoft.Extensions.Http.Resilience?#

You use both together, not one instead of the other. Microsoft.Extensions.Http.Resilience is built on Polly and is the fastest way to add resilience to IHttpClientFactory clients; drop down to raw ResiliencePipelineBuilder when you need a strategy or a call path (non-HTTP, or a hand-tuned combination) the standard and custom HTTP handlers do not cover.

What is the difference between AddStandardResilienceHandler and AddResilienceHandler?#

AddStandardResilienceHandler registers a fixed, five-layer pipeline (rate limiter, total timeout, retry, circuit breaker, attempt timeout) with production defaults you tune through Configure. AddResilienceHandler gives you an empty pipeline builder and DI-friendly registration, so you choose exactly which strategies to add and in what order.

When should I use hedging instead of a plain retry?#

Use hedging when tail latency, not outright failure, is the problem: a request that is simply slow gets a second attempt racing it before either fails. Reserve it for idempotent, side-effect-free calls, since more than one attempt may reach the server, and prefer plain retry when your real concern is transient errors rather than slow-but-eventually-successful responses.

How do I test my resilience pipeline actually works?#

Use Simmy to inject faults, latency, fake outcomes or arbitrary behavior into a copy of your real pipeline in a test or staging environment, and verify retries, circuit breaking and fallback trigger the way you expect. Testing against a dependency that always succeeds only proves the happy path, not the resilience code.

Should Dapr or Polly own retries when both are available?#

Pick one per call path. If Dapr resiliency policies already retry calls to a target app, do not also wrap the same call in a retrying Polly pipeline, or a single failure multiplies into far more attempts than either policy was tuned for on its own.

Summary#

  • Polly v8's ResiliencePipeline is the engine; Microsoft.Extensions.Http.Resilience is the opinionated, DI-friendly layer for IHttpClientFactory built on top of it.
  • AddStandardResilienceHandler gives you rate limiting, a total timeout, retry, a circuit breaker and an attempt timeout in one call, all overridable.
  • Use exponential backoff with jitter for retries, tie circuit breaker thresholds to real traffic, and keep attempt timeouts well below total timeouts.
  • Hedging and fallback trade extra requests or degraded responses for better tail latency and availability, but only for calls where that trade-off is safe.
  • Avoid retry storms and never retry a non-idempotent operation without an idempotency key; test the whole pipeline with Simmy before you trust it in production.

Further Reading#