Resilience: Polly v8
Key Points
- Polly v8 introduced
ResiliencePipeline— replaces olderPolicy<T>API. More performant, composable, OpenTelemetry-friendly. - Microsoft.Extensions.Resilience is the official wrapper — first-class with
IHttpClientFactory, DI,IOptions, telemetry. - Strategies: retry, circuit breaker, timeout, rate limiter, fallback, hedging, chaos.
- Compose with care: order matters. Typical: outer (timeout for whole op) → retry → circuit breaker → inner (per-attempt timeout).
- Don't retry everything: 4xx (mostly), validation errors, auth failures don't recover with retry. 5xx, transient network errors, 408/429 do.
Concepts (deep dive)
Setup
builder.Services.AddHttpClient("api", c => c.BaseAddress = new Uri("https://api/"))
.AddStandardResilienceHandler(); // default policies for HTTP
AddStandardResilienceHandler adds: total timeout (30s), retry (3 with backoff), circuit breaker, attempt timeout (10s).
Custom pipeline
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddTimeout(TimeSpan.FromSeconds(30)) // overall timeout
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromSeconds(1),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => r.StatusCode == HttpStatusCode.ServiceUnavailable)
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.5,
MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(15)
})
.AddTimeout(TimeSpan.FromSeconds(10)) // per-attempt timeout
.Build();
var response = await pipeline.ExecuteAsync(async ct => await _http.GetAsync(url, ct));
Strategies
Retry
.AddRetry(new()
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential, // 1s, 2s, 4s
UseJitter = true // randomized
})
Always use jitter — prevents thundering herd when many clients retry simultaneously.
Don't retry: - 4xx (except 408 timeout, 429 rate-limit, sometimes 401 after refresh). - Non-idempotent operations on uncertain failure (POST without idempotency key). - Validation errors.
Circuit Breaker
.AddCircuitBreaker(new()
{
FailureRatio = 0.5, // 50% of calls failed
MinimumThroughput = 20, // ...within 20 calls
SamplingDuration = 30s, // ...in last 30s
BreakDuration = 15s // open circuit for 15s
})
States:
Open circuit fails fast — saves load on failing dependency.
Timeout
Pessimistic timeout cancels the operation. Pair with cooperative cancellation in your code.
Rate Limiter
.AddRateLimiter(new SlidingWindowRateLimiter(new() { PermitLimit = 100, Window = TimeSpan.FromSeconds(1) }))
Limits operations per time window — protects you (the client) from overwhelming downstream.
Fallback
.AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
{
FallbackAction = args => Outcome.FromResultAsValueTask(
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]") }),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
})
Returns a default value when the operation fails after all retries.
Hedging
.AddHedging(new HedgingStrategyOptions<HttpResponseMessage>
{
MaxHedgedAttempts = 2,
Delay = TimeSpan.FromMilliseconds(100)
})
Issues parallel requests after a delay. First successful response wins. Useful for tail-latency reduction (some calls ~100ms, occasional ~5s outliers).
Chaos (testing)
.AddChaosLatency(0.1, TimeSpan.FromSeconds(5)) // 10% chance, +5s latency
.AddChaosFault(0.05, () => new HttpRequestException())
Inject controlled failures. Run in non-prod. Helps test resilience.
Composition order
[outer] Timeout (30s total) →
[mid] Retry (3x) →
[inner] Circuit Breaker →
[innermost] Per-attempt Timeout (10s)
- Outermost timeout caps the whole operation.
- Retry sits inside total timeout.
- Circuit breaker inside retry — opens on aggregate failures.
- Inner timeout per attempt.
Order matters: putting circuit breaker outside retry means each retry doesn't see breaker state.
Standard resilience handler vs custom
.AddStandardResilienceHandler() // sensible defaults
.AddStandardResilienceHandler(o => { o.Retry.MaxRetryAttempts = 5; }) // tweak
For 95% of HTTP cases, defaults are great. Custom for special needs.
Hedging strategy
Pre-built hedging for HTTP. Reduces tail latency at cost of doubled traffic (worst case).
OTel integration
Polly v8 emits Microsoft.Extensions.Resilience metrics: - resilience.execution.duration - resilience.execution.attempts - resilience.circuit_breaker.state
Auto-instrumented by OpenTelemetry.Instrumentation.Http.Resilience.
Per-context configuration
var ctx = ResilienceContextPool.Shared.Get();
ctx.Properties.Set(new ResiliencePropertyKey<string>("operationName"), "GetUser");
await pipeline.ExecuteAsync(async ctx => await Op(ctx), ctx);
ResilienceContextPool.Shared.Return(ctx);
Lets strategies make context-aware decisions.
Idempotency caveat
Retrying non-idempotent operations is dangerous. POST without idempotency key + retry on uncertain failure = duplicate side effect.
Always: - Add idempotency keys to non-idempotent operations. - Or use sagas/outbox.
Polly v7 vs v8
- v7:
Policy.Handle<>().RetryAsync(). Reflection-heavy; slower. - v8:
ResiliencePipelineBuilder. Source-gen options; faster; better OTel.
Migrate v7 → v8. Both can coexist during migration.
Don't reinvent
Polly is mature. Don't write your own retry loop with for + await Task.Delay. Polly handles cancellation, telemetry, jitter, edge cases.
Code: correct vs wrong
❌ Wrong: retry without jitter
Thundering herd risk.
✅ Correct: jitter on
❌ Wrong: retry on everything
✅ Correct: ShouldHandle
.AddRetry(new() { ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>().HandleResult(r => (int)r.StatusCode >= 500) })
❌ Wrong: retry on non-idempotent POST
✅ Correct: idempotency key
Design patterns for this topic
Pattern 1 — "Standard handler for HTTP"
- Intent: sensible defaults; minimal config.
Pattern 2 — "Outer-to-inner: timeout → retry → CB → attempt timeout"
- Intent: correct order.
Pattern 3 — "Jitter on every retry"
- Intent: avoid thundering herd.
Pattern 4 — "Hedging for tail latency"
- Intent: reduce p99 at cost of doubled traffic.
Pattern 5 — "Idempotency key for non-idempotent retries"
- Intent: safe retries.
Pros & cons / trade-offs
| Strategy | Pros | Cons |
|---|---|---|
| Retry | Recovers transient | Doubles load on persistent failure |
| Circuit Breaker | Fail fast on outage | Tuning complexity |
| Timeout | Bounded latency | Cancels possibly-recoverable |
| Hedging | Lower p99 | More traffic |
| Fallback | Graceful degradation | Stale data |
When to use / when to avoid
- Always retry + circuit breaker + timeout for outbound HTTP.
- Use hedging for tail-latency-critical reads.
- Avoid retry on non-idempotent without idempotency key.
- Avoid retry on 4xx (with exceptions).
Interview Q&A
Q1. Polly v8 vs v7? v8: ResiliencePipelineBuilder; faster; OTel-native; preferred. v7: Policy classes; reflection-heavy.
Q2. Why jitter? Without it, all clients retry simultaneously after a backoff — thundering herd. Jitter randomizes delays.
Q3. Composition order? Outer: total timeout. Mid: retry. Inner: circuit breaker. Innermost: per-attempt timeout.
Q4. When NOT retry? 4xx (most), validation errors, auth failures, non-idempotent without key.
Q5. Circuit breaker states? Closed → Open → Half-Open → Closed (or back to Open).
Q6. Hedging — how works? Issue parallel requests after delay. First success wins. Reduces tail latency.
Q7. Standard resilience handler? Pre-built HTTP pipeline with sensible retry/CB/timeout. Minimal config.
Q8. Why per-attempt + total timeout? Per-attempt: don't waste time on hung call. Total: cap whole op including retries.
Q9. Idempotency for retried POSTs? Idempotency-Key header. Server dedups by key.
Q10. Polly OTel integration? Built-in metrics; auto-traced when OTel registered.
Q11. Chaos testing in Polly? AddChaosLatency, AddChaosFault — inject controlled failures. Non-prod only.
Q12. Rate limiter use? Self-throttle: don't overwhelm downstream. Different from server-side rate limiting.
Gotchas / common mistakes
- ⚠️ Retry without ShouldHandle — retrying unrecoverable errors.
- ⚠️ No jitter — thundering herd.
- ⚠️ Wrong composition order — circuit breaker outside retry useless.
- ⚠️ Retry non-idempotent POST — duplicates.
- ⚠️ Per-attempt timeout > total timeout — total wins; retry pointless.