# Polly v8: Resilience Pipelines Explained

> Polly v8 replaced policies with resilience pipelines: a rewritten core with allocation-free execution, built-in telemetry, and one composition model instead of PolicyWrap. Here is how the new API maps to the old one, how to register pipelines with DI, and why the order you add strategies changes what your pipeline actually does.

Published: 2026-07-04. Last updated: 2026-07-14. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/polly-v8-resilience-pipelines

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:

```csharp
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:

```bash
dotnet add package Polly.Core
```

```csharp
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](https://milanjovanovic.tech/blogs/articles/polly-v8-resilience-pipelines/strategy-execution-order.png)

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

```csharp
// 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](https://milanjovanovic.tech/blogs/articles/polly-v8-resilience-pipelines/timeout-placement.png)

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:

```bash
dotnet add package Polly.Extensions
```

```csharp
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`:

```csharp
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:

```bash
dotnet add package Microsoft.Extensions.Http.Resilience
```

```csharp
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**](https://milanjovanovic.tech/blog/overriding-default-http-resilience-handlers-in-dotnet) or use `AddResilienceHandler` to compose your own; the broader patterns are covered in [**building resilient cloud applications with .NET**](https://milanjovanovic.tech/blog/building-resilient-cloud-applications-with-dotnet).

## 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.
