Polly Resilience Pipelines
Build Polly 8 pipelines with retry, backoff, jitter, timeout and circuit breaker, and register them in DI for non-HTTP work.
new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { BackoffType = DelayBackoffType.Exponential, UseJitter = true })
.AddTimeout(TimeSpan.FromSeconds(2))
.Build();Polly 8 (Polly.Core 8.8.0) builds a ResiliencePipeline from strategies with ResiliencePipelineBuilder. Polly.Core alone gives you only the builder; DI registration, logging and metrics come from the Polly.Extensions package (also brought in by Microsoft.Extensions.Resilience and Microsoft.Extensions.Http.Resilience). The HTTP resilience handler is built on it; use Polly directly for everything that is not an HttpClient: a cloud SDK call, a message publish, a Redis command, a gRPC call made by hand.
Order is the design
The first strategy added is the outermost. AddRetry(...).AddTimeout(2s) gives each attempt 2 seconds; AddTimeout(2s).AddRetry(...) gives all attempts together 2 seconds. A common production shape is total timeout, then retry, then circuit breaker, then attempt timeout, which is exactly the standard HTTP pipeline.
| Strategy | Builder method | Defaults | Throws when it gives up |
|---|---|---|---|
| Retry | AddRetry(RetryStrategyOptions) | 3 retries, 2s, constant, no jitter, all exceptions except OperationCanceledException | The last exception |
| Timeout | AddTimeout(TimeSpan) | 30 seconds | TimeoutRejectedException |
| Circuit breaker | AddCircuitBreaker(...) | 10% failures, 100 calls minimum, 30s window, 5s break | BrokenCircuitException |
| Fallback | AddFallback(...) (generic pipelines) | None | Returns the fallback value |
Retries that do not stampede
The retry defaults are rarely what you want. Set BackoffType = DelayBackoffType.Exponential and UseJitter = true so a thousand clients that failed together do not retry together; set MaxDelay, which is unset by default, so the delay cannot grow past what the caller can wait. Always narrow ShouldHandle with PredicateBuilder to the transient errors of that dependency: retrying a validation error or an authorisation failure only adds latency.
Timeouts are cooperative
The timeout strategy cancels the CancellationToken it passes to your callback. If the callback ignores that token, the pipeline still waits for it to finish and only then throws. Always use the lambda overload that receives the token, ExecuteAsync(async token => await store.SaveAsync(doc, token), ct), and pass it all the way down.
Pipelines are singletons
A pipeline is thread-safe and holds state: the circuit breaker's health counters live in it. Building one per call means a breaker that never opens. Register it once with AddResiliencePipeline("name", (builder, context) => ...) (from Polly.Extensions) and resolve it from ResiliencePipelineProvider<string>. Registering through DI also wires logging and metrics: every retry, timeout and circuit state change is logged through ILoggerFactory and emitted on the Polly meter.
Where retries are wrong
- Inside a database transaction: the retry re-runs half the unit of work. Retry the whole transaction, or use the provider's execution strategy.
- Around non-idempotent writes with no de-duplication key on the other side.
- Around a call the caller will itself retry, which multiplies attempts.
Teams on Polly 7 (Policy.Handle<T>().WaitAndRetryAsync, PolicyWrap) should migrate: v8's pipelines allocate less, are built once and have first-class telemetry.
Example
using Polly;
using Polly.CircuitBreaker;
using Polly.Registry;
using Polly.Retry;
using Polly.Timeout;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddResiliencePipeline("invoice-archive", pipeline => pipeline
.AddTimeout(TimeSpan.FromSeconds(5))
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<IOException>().Handle<TimeoutRejectedException>(),
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(100),
MaxDelay = TimeSpan.FromSeconds(1)
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
// A hanging endpoint fails by timing out, so attempt timeouts must count as failures.
ShouldHandle = new PredicateBuilder().Handle<IOException>().Handle<TimeoutRejectedException>(),
FailureRatio = 0.5,
MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(20)
})
.AddTimeout(TimeSpan.FromMilliseconds(800)));
builder.Services.AddSingleton<FlakyBlobStore>();
builder.Services.AddSingleton<InvoiceArchive>();
using var host = builder.Build();
var archive = host.Services.GetRequiredService<InvoiceArchive>();
foreach (var number in new[] { "INV-2026-0041", "INV-2026-0042" })
{
var location = await archive.ArchiveAsync(number, CancellationToken.None);
Console.WriteLine($"{number} archived at {location}");
}
public sealed class InvoiceArchive(ResiliencePipelineProvider<string> pipelines, FlakyBlobStore store)
{
private readonly ResiliencePipeline pipeline = pipelines.GetPipeline("invoice-archive");
public async Task<string> ArchiveAsync(string invoiceNumber, CancellationToken ct) =>
await pipeline.ExecuteAsync(
async token => await store.UploadAsync($"invoices/2026/{invoiceNumber}.pdf", token),
ct);
}
// Stands in for a storage SDK: the first two calls for each blob fail with a transient error.
public sealed class FlakyBlobStore
{
private readonly Dictionary<string, int> attempts = new();
public async Task<string> UploadAsync(string path, CancellationToken ct)
{
int attempt;
lock (attempts)
{
attempts[path] = attempt = attempts.GetValueOrDefault(path) + 1;
}
await Task.Delay(20, ct);
Console.WriteLine($" upload {path}, attempt {attempt}");
if (attempt < 3)
{
throw new IOException("Connection reset by storage endpoint");
}
return $"https://blobs.shop.example/{path}";
}
}
When to use it
- Retry an S3 or Blob Storage upload on connection resets with exponential backoff and jitter, capped at 1 second between attempts.
- Degrade the product page to no recommendations while the recommendation service's circuit is open.
- Give a broker publish 800 ms per attempt and 5 seconds in total so a stuck connection cannot hold an order request.
More examples
A generic pipeline that retries on a result
A typed pipeline, <code>ResiliencePipeline<ChargeResult></code>, can inspect results as well as exceptions, which is needed when an SDK returns a status instead of throwing. The <code>static</code> lambda with a state argument avoids a closure allocation per call. <code>Declined</code> is a final answer and is deliberately not in <code>ShouldHandle</code>, so it maps to 422. A processor still busy after the retries is transient, so it maps to 503 with <code>Retry-After</code>, never to a 4xx that tells the caller its request was wrong.
using Polly;
using Polly.Registry;
using Polly.Retry;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResiliencePipeline<string, ChargeResult>("card-charge", pipeline => pipeline
.AddRetry(new RetryStrategyOptions<ChargeResult>
{
// The SDK reports a busy processor in the result instead of throwing.
ShouldHandle = new PredicateBuilder<ChargeResult>()
.HandleResult(r => r.Status == ChargeStatus.ProcessorBusy)
.Handle<HttpRequestException>(),
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(300)
})
.AddTimeout(TimeSpan.FromSeconds(3)));
var app = builder.Build();
app.MapPost("/payments", async (ChargeRequest request, ResiliencePipelineProvider<string> pipelines,
HttpContext httpContext, CancellationToken ct) =>
{
var pipeline = pipelines.GetPipeline<ChargeResult>("card-charge");
var result = await pipeline.ExecuteAsync(
static async (state, token) => await CardProcessor.ChargeAsync(state, token), request, ct);
if (result.Status == ChargeStatus.ProcessorBusy)
{
// Still busy after the retries: transient, so tell the caller to come back.
httpContext.Response.Headers.RetryAfter = "5";
return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Card processor busy");
}
return result.Status == ChargeStatus.Approved ? Results.Ok(result) : Results.UnprocessableEntity(result);
});
app.Run();
public sealed record ChargeRequest(string PaymentMethodId, long AmountMinor, string Currency, string IdempotencyKey);
public sealed record ChargeResult(string ChargeId, ChargeStatus Status);
public enum ChargeStatus { Approved, Declined, ProcessorBusy }
public static class CardProcessor
{
public static async Task<ChargeResult> ChargeAsync(ChargeRequest request, CancellationToken ct)
{
await Task.Delay(50, ct);
return new ChargeResult($"ch_{request.IdempotencyKey}", ChargeStatus.Approved);
}
}
Reacting to an open circuit
While the circuit is open, calls fail in microseconds with <code>BrokenCircuitException</code>, so the product page stays fast and the struggling service gets time to recover. After <code>BreakDuration</code> one trial call is let through (half-open); success closes the circuit, failure opens it again. Whether to degrade or return 503 depends on whether the data is optional.
using Polly;
using Polly.CircuitBreaker;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResiliencePipeline("recommendations", (pipeline, context) =>
{
var logger = context.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Recommendations");
pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
MinimumThroughput = 20,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(30),
OnOpened = args =>
{
logger.LogWarning("Recommendations circuit opened for {Break}", args.BreakDuration);
return ValueTask.CompletedTask;
}
});
});
var app = builder.Build();
app.MapGet("/products/{sku}/recommendations", async (string sku, Polly.Registry.ResiliencePipelineProvider<string> pipelines, CancellationToken ct) =>
{
try
{
var items = await pipelines.GetPipeline("recommendations")
.ExecuteAsync(async token => await RecommendationEngine.ForAsync(sku, token), ct);
return Results.Ok(items);
}
catch (BrokenCircuitException)
{
// Recommendations are optional: degrade to an empty list instead of failing the page.
return Results.Ok(Array.Empty<string>());
}
});
app.Run();
static class RecommendationEngine
{
public static async Task<string[]> ForAsync(string sku, CancellationToken ct)
{
await Task.Delay(30, ct);
return [$"{sku}-FILTER", $"{sku}-DESCALER"];
}
}
Testing a retry pipeline with xUnit v3
Setting <code>Delay</code> to zero keeps the test instant while still exercising the real retry logic. The second test pins down <code>ShouldHandle</code>: a refactor that widens it to every exception would retry permanent errors, and this test fails. For delay-sensitive tests, set the builder's <code>TimeProvider</code> to a <code>FakeTimeProvider</code> and advance time explicitly.
using Polly;
using Polly.Retry;
using Xunit;
public class InvoiceArchivePipelineTests
{
private static ResiliencePipeline BuildPipeline() => new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<IOException>(),
MaxRetryAttempts = 3,
Delay = TimeSpan.Zero
})
.Build();
[Fact]
public async Task Retries_transient_io_errors_until_success()
{
var attempts = 0;
var result = await BuildPipeline().ExecuteAsync(async token =>
{
attempts++;
await Task.Yield();
if (attempts < 3) throw new IOException("reset");
return "stored";
}, TestContext.Current.CancellationToken);
Assert.Equal("stored", result);
Assert.Equal(3, attempts);
}
[Fact]
public async Task Does_not_retry_validation_errors()
{
var attempts = 0;
await Assert.ThrowsAsync<ArgumentException>(async () =>
await BuildPipeline().ExecuteAsync(token =>
{
attempts++;
throw new ArgumentException("Invoice number is malformed");
}, TestContext.Current.CancellationToken));
Assert.Equal(1, attempts);
}
}
Discussion