Polly v8: Resilience Pipelines Explained

Polly v8: Resilience Pipelines Explained

6 min read··Updated ·

aspnetcoredotnetpollyresilience

Polly v8 rebuilt the library around a single concept: the resilience pipeline, an ordered composition of strategies (retry, circuit breaker, timeout) that you build once through ResiliencePipelineBuilder and cache. It replaces PolicyWrap, unifies sync and async in one API, moves configuration to options classes, and builds telemetry in.

If you learned Polly before 2023, your muscle memory says Policy.Handle<HttpRequestException>().WaitAndRetryAsync(...) and Policy.WrapAsync(retry, breaker, timeout). Throwing that API out wasn't churn for its own sake. The rewrite (done in collaboration with Microsoft, who built Microsoft.Extensions.Http.Resilience on top of it) fixed real problems: duplicated sync/async APIs, allocation-heavy execution, bolt-on telemetry, and the perpetually confusing PolicyWrap ordering.

Here's the new model, the migration mapping, and the one behavior (strategy ordering) that deserves more attention than it gets.

From Policies to Pipelines

The v8 mental model has three pieces:

  • A strategy is one resilience behavior: retry, circuit breaker, timeout, rate limiter, fallback, hedging.
  • A pipeline is an ordered composition of strategies, built once and cached.
  • Everything is configured through options classes with ShouldHandle predicates, instead of fluent Handle chains.

Side by side. Polly v7:

var retry = Policy
    .Handle<HttpRequestException>()
    .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));

var timeout = Policy.TimeoutAsync(TimeSpan.FromSeconds(10));

var wrapped = Policy.WrapAsync(retry, timeout);

await wrapped.ExecuteAsync(() => httpClient.GetAsync(url));

Polly v8:

dotnet add package Polly.Core
using Polly;
using Polly.Retry;

ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>(),
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true
    })
    .AddTimeout(TimeSpan.FromSeconds(10))
    .Build();

await pipeline.ExecuteAsync(
    async ct => await httpClient.GetAsync(url, ct),
    cancellationToken);

Details worth noticing:

  • One API for sync and async. ResiliencePipeline has Execute and ExecuteAsync; the v7 split between Policy and AsyncPolicy is gone.
  • Cancellation is first-class. The callback receives a CancellationToken that the pipeline manages; a timeout strategy cancels your delegate through it, not by abandoning it.
  • Jitter is a property, not a contrib package. Exponential backoff with jitter is UseJitter = true.
  • Generic pipelines (ResiliencePipeline<HttpResponseMessage>) handle result-based conditions, like retrying on 5xx status codes, and are required for result-producing strategies like fallback and hedging.
  • Build once, reuse forever. Pipelines are thread-safe and designed to be cached. Building one per request wastes the allocation work v8 did; the execution path itself is designed to be allocation-free.

The migration mapping, compactly: WaitAndRetryAsync becomes AddRetry with RetryStrategyOptions; AdvancedCircuitBreakerAsync becomes AddCircuitBreaker with failure-ratio options; TimeoutAsync becomes AddTimeout; BulkheadAsync becomes AddConcurrencyLimiter (the bulkhead pattern under a more accurate name); FallbackAsync becomes AddFallback; and PolicyWrap disappears entirely, because the pipeline is the composition. The v8 package still ships the legacy API, so migration can be incremental.

Why Does Strategy Order Matter?

PolicyWrap confused everyone about what wrapped what. Pipelines make it deterministic: strategies execute in the order added, first added is outermost. A call flows inward through each strategy to your delegate, and the outcome flows back out through them in reverse.

A pipeline built from a rate limiter, total timeout, retry, circuit breaker, and per-attempt timeout, with the call flowing left to right from the caller inward to the delegate; the first strategy added is the outermost

This isn't cosmetic. The same strategies in a different order are a different machine. The canonical example is timeout placement relative to retry:

// A: timeout OUTSIDE retry. One 10-second budget for ALL attempts.
new ResiliencePipelineBuilder()
    .AddTimeout(TimeSpan.FromSeconds(10))
    .AddRetry(retryOptions)
    .Build();

// B: timeout INSIDE retry. Each attempt gets its own 10 seconds.
new ResiliencePipelineBuilder()
    .AddRetry(retryOptions)
    .AddTimeout(TimeSpan.FromSeconds(10))
    .Build();
Two pipelines compared: A puts the timeout outside the retry so all attempts share one 10-second budget, while B puts the timeout inside the retry so each attempt gets its own 10 seconds

In A, retries race a shared deadline: the third attempt might get 1 second, and TimeoutRejectedException escaping the pipeline means the whole operation is over. In B, three attempts can burn 30 seconds plus backoff, and the retry sees each timeout and decides whether to go again. Neither is wrong; they answer different questions, and a robust pipeline often uses both, which is the heart of a sane timeout strategy.

The recommended general-purpose order, which is also what Microsoft's standard handler uses:

  1. Rate limiter or concurrency limiter (shed load before spending effort on it)
  2. Total timeout (the overall budget)
  3. Retry
  4. Circuit breaker (inside retry, so it sees every raw attempt and its failure stats stay honest; the retry then sees BrokenCircuitException and stops)
  5. Per-attempt timeout

The retry vs circuit breaker interaction in step 4 is the subtlest part of the ordering, and the one worth internalizing before you tune any thresholds. And if you add chaos strategies with Simmy, they go last, innermost, so injected faults pass through all the real strategies.

Dependency Injection and Reuse

Polly.Extensions adds the DI registration model. You register a pipeline under a key, and Polly caches it:

dotnet add package Polly.Extensions
builder.Services.AddResiliencePipeline("database", pipeline =>
{
    pipeline
        .AddTimeout(TimeSpan.FromSeconds(15))
        .AddRetry(new RetryStrategyOptions
        {
            ShouldHandle = new PredicateBuilder()
                .Handle<NpgsqlException>(ex => ex.IsTransient),
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true
        });
});

Consume it through ResiliencePipelineProvider:

public class OrderRepository(
    ResiliencePipelineProvider<string> pipelineProvider,
    NpgsqlDataSource dataSource)
{
    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        var pipeline = pipelineProvider.GetPipeline("database");

        return await pipeline.ExecuteAsync(
            async token => await QueryOrderAsync(dataSource, id, token),
            ct);
    }
}

Registering through DI buys you the second headline feature: telemetry is on by default. Every strategy emits events (retry attempts, breaker state changes, timeouts) through ILogger and System.Diagnostics.Metrics, so the answer to "did the retry actually fire last night?" is in your logs and your OpenTelemetry metrics without any instrumentation code. In v7, that visibility was something you hand-rolled in onRetry callbacks, or more commonly didn't.

HttpClient: You Might Not Need to Build a Pipeline at All

For HTTP, Microsoft.Extensions.Http.Resilience packages the whole thing:

dotnet add package Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient("catalog", client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
})
.AddStandardResilienceHandler();

That one line installs the recommended composition: rate limiter, 30-second total timeout, retry (3 attempts, exponential, jittered, honoring Retry-After), circuit breaker, and a 10-second per-attempt timeout, in exactly the outermost-to-innermost order listed earlier. It's the productized version of everything this article described, and for service-to-service HTTP it's the right default. When the defaults don't fit, override the standard resilience handlers or use AddResilienceHandler to compose your own; the broader patterns are covered in building resilient cloud applications with .NET.

Summary

Polly v8 is a better model, not just a new API.

  • Pipelines replace policies and PolicyWrap: one composition mechanism, options-based configuration, unified sync/async, build-once-and-cache.
  • Ordering is explicit and semantic: first added is outermost, and moving a timeout across a retry changes what your pipeline promises.
  • DI registration gives you cached pipelines plus logs and metrics for every strategy activation, which turns "is our resilience working?" into a dashboard query.
  • For HTTP, start with AddStandardResilienceHandler and customize only when you outgrow it.

Migrate incrementally; the legacy API still works. But write new resilience code as pipelines, and spend the time you save thinking about the part the library can't do for you: which strategies, with which thresholds, in which order.

Frequently Asked Questions

What changed between Polly v7 and v8?

V8 replaced the policy model (Policy.Handle, WaitAndRetryAsync, PolicyWrap) with resilience pipelines built through ResiliencePipelineBuilder. Strategies are configured with options classes, sync and async are unified in one API, telemetry is built in, and execution is optimized to avoid allocations on the hot path.

How do I register Polly pipelines with dependency injection?

Use services.AddResiliencePipeline(key, builder => ...) from Polly.Extensions, then resolve ResiliencePipelineProvider and call GetPipeline(key). For HttpClient, Microsoft.Extensions.Http.Resilience wires pipelines into the handler chain with AddResilienceHandler or AddStandardResilienceHandler.

Does strategy order matter in a Polly pipeline?

Yes, decisively. Strategies execute in the order added: the first is outermost. A timeout added before a retry caps the total time for all attempts, while a timeout added after the retry caps each individual attempt. The same strategies in a different order produce different behavior.

Do I have to migrate from Polly v7?

Not immediately: the v8 package still ships the v7 API for compatibility. But new features, chaos strategies, performance work, and the Microsoft.Extensions.Http.Resilience integration all target the pipeline API, so new code should use pipelines and existing policies can migrate incrementally.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.