Polly v8 Resilience Pipelines in .NET — Retry, Circuit Breaker, Hedging
Build resilient .NET applications with Polly v8: new pipeline API, retry with exponential backoff, circuit breaker, timeout, hedging for parallel requests, and HttpClient integration.
Polly v8 Resilience Pipelines in .NET — Retry, Circuit Breaker, Hedging
Polly v8 rewrites the API around pipelines: composable, type-safe resilience strategies that integrate natively with .NET DI and HttpClientFactory. The old Policy.Handle<>().WaitAndRetry() API still works but is superseded.
Polly v8 — What Changed
Polly v7 (old):
var policy = Policy
.Handle()
.WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(Math.Pow(2, i)));
await policy.ExecuteAsync(() => httpClient.GetAsync(url));
Polly v8 (new):
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
.AddCircuitBreaker(new CircuitBreakerStrategyOptions())
.Build();
await pipeline.ExecuteAsync(async ct => await httpClient.GetAsync(url, ct));
Key differences:
- ResiliencePipeline replaces Policy
- Strategies compose via builder (not chained .Wrap())
- Native CancellationToken propagation
- Telemetry built in (OpenTelemetry events per attempt)
- New hedging strategy (parallel speculative requests) Step 1: Install Packages
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.*" />
<PackageReference Include="Polly.Extensions" Version="8.*" />Step 2: HttpClient Resilience (Recommended Approach)
// Program.cs — wire resilience directly into HttpClientFactory
builder.Services.AddHttpClient<IPaymentClient, PaymentClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Payment:BaseUrl"]!);
client.Timeout = TimeSpan.FromSeconds(30);
})
// Standard resilience handler — retry + circuit breaker + timeout configured sensibly
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true; // avoid thundering herd
options.CircuitBreaker.FailureRatio = 0.5; // open at 50% failure rate
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.MinimumThroughput = 10; // need at least 10 requests to trigger
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
});Step 3: Custom Retry Pipeline
// Fine-grained control — build your own pipeline
builder.Services.AddResiliencePipeline("payment-retry", builder =>
{
builder
.AddRetry(new RetryStrategyOptions
{
// Only retry on transient errors
ShouldHandle = new PredicateBuilder()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult<HttpResponseMessage>(r =>
r.StatusCode is HttpStatusCode.TooManyRequests
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout),
MaxRetryAttempts = 4,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
MaxDelay = TimeSpan.FromSeconds(30),
// Observe every retry attempt
OnRetry = args =>
{
var logger = args.Context.ServiceProvider?.GetService<ILogger<Program>>();
logger?.LogWarning(
"Retry {Attempt} after {Delay}ms. Outcome: {Outcome}",
args.AttemptNumber + 1,
args.RetryDelay.TotalMilliseconds,
args.Outcome.Exception?.Message ?? args.Outcome.Result?.StatusCode.ToString());
return ValueTask.CompletedTask;
},
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(10),
MinimumThroughput = 5,
BreakDuration = TimeSpan.FromSeconds(30),
OnOpened = args =>
{
args.Context.ServiceProvider?
.GetService<ILogger<Program>>()?
.LogError("Circuit breaker OPENED. Break duration: {Duration}s",
args.BreakDuration.TotalSeconds);
return ValueTask.CompletedTask;
},
})
.AddTimeout(TimeSpan.FromSeconds(8));
});
// Use the pipeline
public class PaymentService(
HttpClient http,
ResiliencePipelineProvider<string> pipelines) : IPaymentClient
{
private readonly ResiliencePipeline _pipeline = pipelines.GetPipeline("payment-retry");
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardToken, CancellationToken ct)
=> await _pipeline.ExecuteAsync(async token =>
{
var response = await http.PostAsJsonAsync("/charge",
new { amount, cardToken }, token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PaymentResult>(token)
?? throw new InvalidOperationException("Empty response");
}, ct);
}Step 4: Circuit Breaker — Understand the States
Circuit Breaker states:
Closed (healthy):
- All requests pass through
- Failure ratio is tracked over the sampling window
- When failure ratio > threshold AND minimum throughput is met: OPEN
Open (failing fast):
- All requests immediately fail with BrokenCircuitException
- No requests reach the downstream service (gives it time to recover)
- After BreakDuration: HALF-OPEN
Half-Open (probing):
- One probe request is allowed through
- If it succeeds: CLOSED
- If it fails: OPEN again (reset timer)
Polly v8: use OnOpened/OnClosed/OnHalfOpened callbacks for alerting.
Never catch BrokenCircuitException silently — return a cached response or degrade gracefully.// Graceful degradation when circuit is open
public class ResilientOrderService(
IOrderRepository orders,
IDistributedCache cache,
ResiliencePipelineProvider<string> pipelines)
{
private readonly ResiliencePipeline _pipeline = pipelines.GetPipeline("order-api");
public async Task<OrderSummary?> GetOrderAsync(int orderId, CancellationToken ct)
{
try
{
return await _pipeline.ExecuteAsync(
async token => await orders.GetByIdAsync(orderId, token), ct);
}
catch (BrokenCircuitException)
{
// Circuit is open — serve from cache
var cached = await cache.GetStringAsync($"order:{orderId}", ct);
return cached is null ? null : JsonSerializer.Deserialize<OrderSummary>(cached);
}
}
}Step 5: Hedging — Parallel Speculative Requests
// Hedging: if the primary request is slow, fire a second one in parallel
// Whichever completes first "wins" — cancel the other
builder.Services.AddResiliencePipeline("search-hedging", builder =>
{
builder.AddHedging(new HedgingStrategyOptions
{
// Fire second request if first hasn't responded in 200ms
Delay = TimeSpan.FromMilliseconds(200),
MaxHedgedAttempts = 2, // try up to 2 parallel requests
// Only hedge on slow response — not on exceptions
ShouldHandle = new PredicateBuilder()
.Handle<TimeoutRejectedException>(),
OnHedging = args =>
{
Console.WriteLine($"Hedging attempt {args.AttemptNumber}: primary was slow");
return ValueTask.CompletedTask;
},
})
.AddTimeout(TimeSpan.FromSeconds(2)); // overall deadline
});
// Useful for: search endpoints, recommendations, any read with P99 latency problems
public class SearchService(
HttpClient http,
ResiliencePipelineProvider<string> pipelines)
{
private readonly ResiliencePipeline<SearchResult[]> _pipeline =
pipelines.GetPipeline<SearchResult[]>("search-hedging");
public Task<SearchResult[]> SearchAsync(string query, CancellationToken ct)
=> _pipeline.ExecuteAsync(async token =>
{
var response = await http.GetAsync($"/search?q={Uri.EscapeDataString(query)}", token);
return await response.Content.ReadFromJsonAsync<SearchResult[]>(token) ?? [];
}, ct);
}Step 6: Rate Limiter Strategy
// Limit outbound request rate — don't overwhelm downstream APIs
builder.Services.AddResiliencePipeline("rate-limited-api", builder =>
{
builder
.AddRateLimiter(new RateLimiterStrategyOptions
{
// Token bucket: 100 permits/minute, replenish 2/second
Limiter = PartitionedRateLimiter.Create<ResilienceContext, string>(
ctx => RateLimitPartition.GetTokenBucketLimiter(
"default",
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
TokensPerPeriod = 2,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10,
})),
})
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
});
});Step 7: Telemetry
// Polly v8 emits OpenTelemetry events automatically
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter("Polly")) // retry counts, circuit state, duration
.WithTracing(t => t.AddSource("Polly")); // span per attemptInterview Answer
"Polly v8 replaces Policy with ResiliencePipeline — a composable builder that chains strategies: retry, circuit breaker, timeout, hedging, and rate limiter. For HttpClient: AddStandardResilienceHandler wires a sensible default pipeline (retry with jitter, circuit breaker, total timeout) with one line. Custom pipelines: AddResiliencePipeline('name', builder => ...) followed by GetPipeline('name') in the consumer. Retry: use exponential backoff with jitter (UseJitter = true) to avoid thundering herd when many instances retry simultaneously. Circuit breaker: opens when failure ratio exceeds the threshold over a sampling window — fast-fails subsequent requests during BreakDuration so the downstream has time to recover. Handle BrokenCircuitException with a cached response or graceful degradation, never silence it. Hedging: fires a parallel speculative request after a delay — useful for high-percentile latency on read endpoints. All strategies emit OpenTelemetry metrics (retry counts, circuit state) automatically — add AddMeter('Polly') to your metrics builder."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.