Learnixo
Back to blog
Backend Systemsintermediate

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.

Asma Hafeez KhanMay 25, 20266 min read
.NETC#Pollyresilienceretrycircuit breakerhedgingHttpClient
Share:𝕏

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

XML
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.*" />
<PackageReference Include="Polly.Extensions" Version="8.*" />

Step 2: HttpClient Resilience (Recommended Approach)

C#
// 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

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

C#
// 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

C#
// 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

C#
// 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 attempt

Interview 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.