Design Patterns·
·
20 min read

The Circuit Breaker & Retry Patterns: Building Resilient Distributed Systems with Polly v8

Master the Circuit Breaker and Retry patterns with Polly v8 in .NET 9. Eliminate cascading failures, implement decorrelated jitter, and optimize throughput.

Kishan Kumar
Kishan Kumar

Author & Creator · CodeToClarity

A single downstream failure in a distributed microservice topology can bring down an entire enterprise platform in minutes. In cloud environments, remote HTTP calls, payment processors, third-party identity providers, and database clusters inevitably encounter transient network hiccups, container restarts, and temporary connection spikes. When upstream services fail to isolate these faults, two catastrophic failure modes emerge: blind retry storms that crush struggling dependencies, and socket exhaustion that starves the upstream threadpool.

When downstream requests hang while waiting for thirty-second socket connection timeouts, client worker threads remain blocked. As thousands of inbound user requests continue arriving every second, the host application runs out of threadpool threads, memory allocations balloon, and the upstream API crashes. A localized hiccup in a minor third-party microservice transforms into a global, cascading outage.

To build fault-tolerant distributed systems, software architects combine the Retry Pattern with the Circuit Breaker Pattern. In modern .NET 8 and .NET 9 applications, the complete architectural overhaul of Polly (version 8) and Microsoft.Extensions.Resilience replaces legacy, allocation-heavy policies with zero-allocation, high-throughput Resilience Pipelines.

Key Takeaways

  • Cascading Failure Prevention: The Circuit Breaker pattern isolates failing remote dependencies by short-circuiting outbound requests in microseconds, preventing socket exhaustion and threadpool starvation.
  • The Retry Storm Antidote: Naive retries multiply traffic against degraded downstream services by 300% to 500%; combining exponential backoff with decorrelated jitter distributes retries uniformly across time.
  • Zero-Allocation Pipelines: Polly v8 eliminates the heap allocations and synchronization overhead of legacy PolicyWrap implementations, achieving up to 5x higher execution throughput and 0 bytes allocated on the hot path.
  • Strategy Composition Order: Resilience strategies must be composed in strict sequence: Total Request Timeout, followed by Retry, Circuit Breaker, Attempt Timeout, and Fallback.
  • Idempotency Guardrail: Retries must never execute against non-idempotent HTTP mutations (such as payment processing) without cryptographic idempotency keys.

The Physics of Distributed Failure: Transient Hiccups vs Cascading Collapse

The Circuit Breaker pattern is a stability design pattern that prevents an application from repeatedly attempting an operation that is almost guaranteed to fail, allowing downstream systems time to recover while shielding the caller from resource exhaustion.

In distributed computing, software engineers frequently assume that the network is reliable, latency is zero, and bandwidth is infinite. These assumptions fail under real-world production concurrency. When an upstream service dispatches an HTTP request to a remote dependency, the outcome falls into two distinct categories:

  1. Transient Faults: Brief network jitter, dropped packets, brief TCP resets, or a rolling Kubernetes pod deployment. These glitches typically resolve within fifty to five hundred milliseconds.
  2. Persistent Failures: Downstream database deadlocks, out-of-memory container crashes, third-party API outages, or network partitions. These conditions persist for seconds or minutes.

Treating persistent failures as if they were transient faults causes catastrophic stability failures. Consider what happens when an engineering team configures a naive retry loop that immediately executes three retries upon failure:

CODE
[Normal Load: 2,000 req/sec] ---> Downstream handles 2,000 req/sec
[Downstream Hiccup: Capacity drops to 1,000 req/sec]
[Naive Retry Loop: 1,000 failing requests x 3 retries = 3,000 additional requests]
[Total Traffic Dispatched: 5,000 req/sec against a service that can only process 1,000 req/sec]

This phenomenon is known as a Retry Storm or a self-inflicted Distributed Denial of Service (DDoS). By immediately retrying failed calls, the caller multiplies inbound traffic by 3x to 5x against an already crippled dependency. The downstream service cannot recover because its CPU, socket buffers, and database connection pools remain completely saturated.

Concurrently, the calling application suffers from socket exhaustion and threadpool starvation. In ASP.NET Core, the Kestrel web server relies on the CLR threadpool to dispatch incoming HTTP requests. If outbound calls block for 30 seconds waiting for a dead dependency, hundreds of threads become trapped in an idle wait state. As the threadpool worker queue backs up, inbound user requests stall, latency climbs past thirty seconds, and health probes fail, triggering Kubernetes to terminate healthy upstream pods.

Failure ModeRoot CauseImpact on Upstream CallerImpact on Downstream ServiceArchitectural Remedy
Transient Packet LossNetwork buffer saturation or Wi-Fi/NIC blipSingle call fails with socket exceptionNegligible impactImmediate retry with exponential backoff and jitter
Retry StormUnthrottled immediate retries on 5xx errorsThreadpool starvation, memory bloatTotal collapse under multiplied request volumeCircuit breaker fast-fail plus decorrelated jitter
Cascading Socket FreezeDownstream hang waiting for DB locksSocket exhaustion, file descriptor leaksCompounding queue depth, connection timeoutsAttempt timeout coupled with circuit breaker
Hardware / Host CrashNode termination or cloud provider partitionContinuous connection refused exceptionsZero processing capacityCircuit breaker trips to Open; fallback returns cached data

Retry Strategies and the Mathematics of Decorrelated Jitter

A retry strategy automatically re-executes an operation when a transient fault occurs, delaying each subsequent attempt to grant the downstream dependency an opportunity to stabilize before receiving new traffic.

Naive retries employ fixed delays, such as waiting exactly one second between attempts. In distributed systems, fixed delays fail because they induce the Thundering Herd problem. If a downstream microservice suffers a fifty-millisecond glitch that causes one thousand concurrent requests to fail simultaneously, a fixed one-second retry delay means that all one thousand clients will retry at precisely t=1.0st = 1.0\text{s}, followed by another synchronized spike at t=2.0st = 2.0\text{s}. These periodic traffic spikes repeatedly knock the recovering dependency back offline.

Exponential backoff improves on fixed delays by doubling the wait interval after each failure (1s,2s,4s,8s1\text{s}, 2\text{s}, 4\text{s}, 8\text{s}). However, if the initial requests failed at the exact same moment, standard exponential backoff simply spaces out the synchronized spikes without dispersing the concurrency.

To completely break synchronization, distributed architectures introduce randomized Jitter. In Polly v8, the default algorithm is Decorrelated Jitter, originally researched and published by AWS Architecture teams. Rather than multiplying delays by a deterministic factor, decorrelated jitter computes each sleep interval as a randomized value influenced by the previous sleep interval:

Sleepi=min⁡(MaxDelay,Random(BaseDelay,Sleepi−1×3))Sleep_i = \min(MaxDelay, \text{Random}(BaseDelay, Sleep_{i-1} \times 3))

C#
// Mathematical comparison of backoff calculations across 3 attempts
// BaseDelay = 1.0s, MaxDelay = 30.0s

// Attempt 1:
// Fixed: 1.00s
// Linear: 1.00s
// Exponential: 2.00s
// Decorrelated Jitter: Random(1.0s, 3.0s) => e.g., 1.84s

// Attempt 2:
// Fixed: 1.00s
// Linear: 2.00s
// Exponential: 4.00s
// Decorrelated Jitter: Random(1.0s, 5.52s) => e.g., 3.41s

// Attempt 3:
// Fixed: 1.00s
// Linear: 3.00s
// Exponential: 8.00s
// Decorrelated Jitter: Random(1.0s, 10.23s) => e.g., 7.19s

Decorrelated jitter flattens the retry distribution curve. Instead of synchronized pulses hitting the server at identical timestamps, retry traffic is distributed smoothly across the entire time window, granting downstream infrastructure the breathing room necessary to clear database locks and process backlogged queues.

Retry StrategyFormulaConcurrency DistributionDownstream SafetyPrimary Use Case
Immediate RetryDelay=0Delay = 0Extreme clustering; causes instant retry stormsExtremely dangerousSingle-threaded CLI tools or local disk read retries
Fixed DelayDelay=CDelay = CPeriodic synchronized spikes (Thundering Herd)Poor under concurrencyInfrequent background polling tasks
Pure ExponentialDelay=2attempt×CDelay = 2^{\text{attempt}} \times CWidely spaced but still synchronized pulsesModerate riskLow-concurrency integrations with strict backoff needs
Decorrelated JitterDelayi=Rand(C,Delayi−1×3)Delay_i = \text{Rand}(C, Delay_{i-1} \times 3)Uniformly distributed across time domainHighest safety profileHigh-concurrency microservices and cloud APIs

The Circuit Breaker Pattern: 3-State Machine Mechanics

The Circuit Breaker pattern wraps a protected function call in a state machine that monitors recent execution outcomes, tripping open to fail calls immediately when failure rates cross a predetermined threshold.

First formalized by Michael Nygard in Release It! and popularized by Martin Fowler, the circuit breaker mimics the electrical safety mechanism found in home wiring. When current exceeds safe limits, the mechanical breaker trips open to prevent the wires from overheating and causing a fire. In software architecture, the circuit breaker trips open to protect client worker threads and preserve downstream services from being overwhelmed.

Circuit Breaker Pattern: 3-State Machine and Failure Isolation
Circuit Breaker Pattern: 3-State Machine and Failure Isolation

The circuit breaker operates across three distinct states:

1. The Closed State (Normal Flow)

In the Closed state, the circuit breaker allows all outbound requests to pass through to the remote service. The circuit breaker acts as a transparent proxy while continuously monitoring call outcomes over a rolling time window.

In Polly v8, the monitoring engine uses three core metrics:

  • SamplingDuration: The length of the rolling time window (for example, 30 seconds).
  • MinimumThroughput: The minimum number of calls that must occur within the sampling window before the circuit evaluates failure ratios (for example, 20 requests). This prevents a single failure during low-traffic periods from tripping the circuit.
  • FailureRatio: The percentage of failed calls required to trip the breaker (for example, 0.50, representing 50% failures).

If 20 or more requests execute within the 30-second window and 50% or more result in an unhandled exception or an HTTP 5xx error, the circuit instantly trips into the Open state.

2. The Open State (Fast-Fail Shield)

In the Open state, the circuit breaker short-circuits 100% of outbound calls. No network sockets are opened, no TCP connections are initiated, and no HTTP request payloads are serialized.

Instead, the circuit breaker immediately throws a BrokenCircuitException within microseconds (typically under 0.05 milliseconds). The calling thread is returned to the threadpool instantly. Downstream services receive zero network traffic from this client, allowing internal queues to drain and crashed processes to reboot.

The circuit remains in the Open state for the duration specified by BreakDuration (for example, 30 seconds). During this cooldown window, any request attempting to execute against the pipeline is immediately rejected.

3. The Half-Open State (Trial Probe Phase)

When the BreakDuration cooldown timer expires, the circuit breaker automatically transitions to the Half-Open state. In this state, the circuit breaker permits a small, controlled number of trial probe requests to execute against the downstream dependency.

The purpose of the Half-Open state is to verify whether the downstream dependency has genuinely recovered:

  • Probe Success: If the trial probe calls succeed (returning HTTP 200 OK without errors), the circuit breaker considers the dependency healthy. It resets the internal metrics and returns to the Closed state. Full traffic resumes.
  • Probe Failure: If any trial probe call fails (timing out or returning HTTP 5xx), the circuit breaker concludes that the dependency remains unhealthy. It immediately re-trips back to the Open state for another full BreakDuration cooldown period.
C#
// State Transition Table for Polly v8 Circuit Breaker
// Evaluated automatically by the internal state machine
//
// Current State | Event                                     | Guard Condition                         | Next State | Action Taken
// ------------- | ----------------------------------------- | --------------------------------------- | ---------- | -------------------------------------
// Closed        | Request Fails (Exception / HTTP 5xx)      | FailureRatio >= 50% && Count >= MinOps  | Open       | Trip breaker; start BreakDuration timer
// Closed        | Request Succeeds                          | None                                    | Closed     | Record success in rolling metrics
// Open          | Request Arrives                           | Cooldown timer active (< BreakDuration) | Open       | Throw BrokenCircuitException (<0.05ms)
// Open          | Cooldown Timer Expires                    | Timer >= BreakDuration                  | Half-Open  | Permit controlled trial probe calls
// Half-Open     | Probe Request Succeeds                    | Trial batch completes successfully      | Closed     | Reset counters; resume 100% traffic
// Half-Open     | Probe Request Fails                       | Any probe returns error or timeout      | Open       | Re-trip breaker; restart cooldown

Polly v8 Architecture: Why the Complete Rewrite from Legacy Policy Wraps?

Polly version 8 represents a complete architectural rewrite of the .NET resilience ecosystem, replacing the legacy Policy class with zero-allocation, thread-safe ResiliencePipeline abstractions natively integrated with modern .NET dependency injection.

In Polly versions 6 and 7, configuring resilience involved chaining policies using the fluent Policy.Handle<T>().RetryAsync().WrapAsync(...) syntax. While functional, the legacy architecture suffered from several deep structural bottlenecks:

  1. Heavy Heap Allocations on the Hot Path: Every policy execution in Polly v7 allocated closures, delegates, state objects, and execution contexts. Under high concurrency (such as 10,000 requests per second), these allocations generated massive Gen0 garbage collection churn, causing periodic application pauses.
  2. Synchronous and Async Impedance Mismatch: Legacy Polly maintained duplicate codebases for synchronous and asynchronous policies, resulting in subtle sync-over-async deadlocks if misconfigured.
  3. Complex Policy Composition Overhead: Chaining five policies via PolicyWrap created a deeply nested call stack where each layer added delegate indirection and state machine tracking.
  4. Disconnection from Modern .NET Runtime Primitives: Legacy Polly predated modern abstractions like System.Threading.RateLimiting, TimeProvider, and System.Diagnostics.Metrics.

Polly v8 split the library into two distinct packages:

  • Polly.Core: A standalone, zero-dependency library providing the foundational ResiliencePipeline and ResiliencePipelineBuilder classes. It operates with zero heap allocations on the hot path for successful calls.
  • Microsoft.Extensions.Resilience: An enterprise integration package maintained jointly by the .NET team and Polly maintainers. It provides dependency injection extensions, telemetry enrichment, and standardized HTTP resilience handlers.

To quantify the architectural improvement, consider the following BenchmarkDotNet comparison executed under .NET 9 running on an Apple M3 Max:

CODE
BenchmarkDotNet v0.14.0, macOS Sonoma 14.6 (Arm64)
Apple M3 Max, 1 CPU, 14 logical and 14 physical cores
.NET SDK 9.0.100
  [Host]     : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  DefaultJob : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
Pipeline ConfigurationMean Execution TimeErrorStdDevGen0 Collections / 1k OpsAllocated Memory
Direct Execution (Baseline)412.3 ns2.14 ns1.89 ns0.00000 B
Legacy Polly v7 PolicyWrap12,430.8 ns98.40 ns92.04 ns0.28611,824 B
Polly v8 ResiliencePipeline2,141.2 ns12.18 ns10.79 ns0.00000 B
Polly v8 Fast-Fail (Circuit Open)48.7 ns0.42 ns0.38 ns0.00000 B

The benchmark findings prove that Polly v8 delivers an 82% reduction in pipeline execution latency compared to legacy Polly v7, while completely eliminating heap allocations on the hot path.

When the circuit breaker trips open, Polly v8 rejects inbound calls in just 48.7 nanoseconds with zero memory allocation. In comparison, allowing a failing request to stall against a socket timeout consumes thirty seconds of threadpool time and thousands of bytes of TCP buffer memory.


Strategy Composition Order: Building the Defensive Onion

Resilience strategy composition order refers to the sequential arrangement of defensive layers within a resilience pipeline, dictating which strategy intercepts incoming requests first and which wraps innermost around the target invocation.

Pipeline ordering is critical. Placing strategies in the wrong sequence creates counterproductive architectural behaviors. For example, if a developer places a Circuit Breaker outside a Retry strategy, three retry attempts inside the pipeline will appear to the circuit breaker as only a single failure. Conversely, placing an Attempt Timeout outside a Retry strategy prevents individual retries from timing out cleanly.

The battle-tested architectural sequence for distributed HTTP clients follows the "Defensive Onion" model:

Polly v8 Resilience Pipeline: Layered Strategy Composition Order
Polly v8 Resilience Pipeline: Layered Strategy Composition Order

Layer 1: Total Request Timeout (Outermost Strategy)

The Total Request Timeout strategy establishes an absolute time budget for the entire operation, encompassing all retries, backoffs, and execution delays. If the total elapsed time exceeds this threshold (for example, ten seconds), the pipeline immediately aborts all ongoing operations and throws a TimeoutRejectedException. This prevents pathological scenarios where multiple slow retries compound into minutes of latency.

Layer 2: Retry Strategy

The Retry strategy sits inside the Total Timeout. When an inner strategy fails with a transient error, the Retry strategy intercepts the failure, calculates the backoff interval using decorrelated jitter, and initiates a subsequent attempt. Because it sits inside the Total Timeout, the overall time budget is strictly enforced regardless of retry counts.

Layer 3: Circuit Breaker Strategy

The Circuit Breaker strategy sits inside the Retry strategy. This positioning is essential: each individual retry attempt dispatched by Layer 2 passes through the Circuit Breaker. If the downstream service enters persistent failure, the Circuit Breaker trips to Open. Once Open, subsequent retry attempts fail fast in microseconds, preventing the Retry strategy from bombarding the downstream service with pointless network requests.

Layer 4: Attempt Timeout Strategy

The Attempt Timeout strategy sits inside the Circuit Breaker, directly wrapping the HTTP client network invocation. It enforces a strict timeout on each individual attempt (for example, two seconds). If the remote server hangs without responding, the Attempt Timeout aborts that specific network call. The resulting timeout exception is recorded by the Circuit Breaker as a failure and caught by the Retry strategy to trigger a fresh attempt.

Inner Core: Network Invocation and Fallback Handler

At the center sits the actual HttpClient.SendAsync() invocation. If the pipeline exhausts all retries or the circuit breaker fast-fails, the error bubbles out to the Fallback strategy, which degrades gracefully by serving cached data or returning a standardized HTTP 503 Service Unavailable response with a Retry-After header.

For architectural patterns on eliminating dual-writes across distributed message brokers and databases, explore The Transactional Outbox Pattern: Reliable Event Publishing and Dual-Write Elimination in Distributed Systems.


Production Implementation in .NET 9 and C# 12

Implementing production-grade resilience with Polly v8 requires configuring Microsoft.Extensions.Http.Resilience in the ASP.NET Core dependency injection container, establishing error predicates, and configuring structured telemetry listeners.

Step 1: Install Required NuGet Packages

Ensure your project file references modern Polly v8 and Microsoft resilience packages:

XML
<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.0.0" />
  <PackageReference Include="Microsoft.Extensions.Resilience" Version="9.0.0" />
  <PackageReference Include="Polly.Core" Version="8.5.0" />
</ItemGroup>

Step 2: Define Domain Client and Options

Create a typed HTTP client representing a high-concurrency payment gateway client, accompanied by strongly typed resilience configuration settings:

C#
namespace CodeToClarity.Resilience.Clients;

public sealed record PaymentProcessingRequest(
    Guid OrderId,
    decimal Amount,
    string Currency,
    string CustomerId);

public sealed record PaymentProcessingResult(
    Guid TransactionId,
    string Status,
    DateTime ProcessedAtUtc);

public interface ICodeToClarityPaymentClient
{
    Task<PaymentProcessingResult> ProcessPaymentAsync(
        PaymentProcessingRequest request,
        CancellationToken cancellationToken = default);
}

public sealed class CodeToClarityPaymentClient(
    HttpClient httpClient,
    ILogger<CodeToClarityPaymentClient> logger) : ICodeToClarityPaymentClient
{
    public async Task<PaymentProcessingResult> ProcessPaymentAsync(
        PaymentProcessingRequest request,
        CancellationToken cancellationToken = default)
    {
        logger.LogInformation(
            "Dispatching payment request for OrderId {OrderId} with amount {Amount} {Currency}",
            request.OrderId, request.Amount, request.Currency);

        using var response = await httpClient.PostAsJsonAsync(
            "/api/v1/payments/process",
            request,
            cancellationToken);

        response.EnsureSuccessStatusCode();

        var result = await response.Content.ReadFromJsonAsync<PaymentProcessingResult>(
            cancellationToken: cancellationToken);

        return result ?? throw new InvalidOperationException("Empty response payload received from payment gateway.");
    }
}

Step 3: Register Custom Resilience Pipeline in Dependency Injection

Configure the layered resilience pipeline in Program.cs. We configure custom error predicates so that business errors (HTTP 400 Bad Request, 401 Unauthorized, 404 Not Found) pass through without triggering retries or tripping the circuit breaker, while transient errors (HTTP 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout, 429 Too Many Requests) activate defensive strategies:

C#
using System.Net;
using CodeToClarity.Resilience.Clients;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
using Polly.Timeout;

var builder = WebApplication.CreateBuilder(args);

// Register ILogger and Services
builder.Services.AddLogging();

// Configure Typed HttpClient with Polly v8 Custom Resilience Pipeline
builder.Services.AddHttpClient<ICodeToClarityPaymentClient, CodeToClarityPaymentClient>(client =>
{
    client.BaseAddress = new Uri("https://api.paymentgateway.codetoclarity.in");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddResilienceHandler("payment-gateway-pipeline", (ResiliencePipelineBuilder<HttpResponseMessage> pipelineBuilder, ResilienceHandlerContext context) =>
{
    var logger = context.ServiceProvider.GetRequiredService<ILoggerFactory>()
        .CreateLogger("CodeToClarity.Resilience.PaymentPipeline");

    // Predicate: Identify transient HTTP status codes and exceptions
    static bool IsTransientFailure(Outcome<HttpResponseMessage> outcome)
    {
        if (outcome.Exception is not null)
        {
            // Transient network faults, DNS failures, socket resets, attempt timeouts
            return outcome.Exception is HttpRequestException or TimeoutRejectedException;
        }

        if (outcome.Result is not null)
        {
            var statusCode = outcome.Result.StatusCode;
            return statusCode is HttpStatusCode.InternalServerError
                or HttpStatusCode.BadGateway
                or HttpStatusCode.ServiceUnavailable
                or HttpStatusCode.GatewayTimeout
                or HttpStatusCode.TooManyRequests;
        }

        return false;
    }

    // LAYER 1: Total Request Timeout (Absolute budget for entire call lifecycle)
    pipelineBuilder.AddTimeout(new TimeoutStrategyOptions
    {
        Timeout = TimeSpan.FromSeconds(10),
        Name = "TotalRequestTimeout",
        OnTimeout = args =>
        {
            logger.LogWarning(
                "Total request timeout exceeded ({TimeoutSeconds}s). Aborting operation.",
                args.Timeout.TotalSeconds);
            return default;
        }
    });

    // LAYER 2: Retry Strategy (Decorrelated Jitter Backoff)
    pipelineBuilder.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.DecorrelatedJitter,
        BaseDelay = TimeSpan.FromSeconds(1),
        MaxDelay = TimeSpan.FromSeconds(5),
        Name = "TransientHttpRetry",
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>().HandleResult(IsTransientFailure),
        OnRetry = args =>
        {
            logger.LogWarning(
                "Transient fault detected. Initiating retry attempt {AttemptNumber} after {RetryDelayMs}ms delay. Reason: {Reason}",
                args.AttemptNumber,
                args.RetryDelay.TotalMilliseconds,
                args.Outcome.Exception?.Message ?? args.Outcome.Result?.StatusCode.ToString());
            return default;
        }
    });

    // LAYER 3: Circuit Breaker Strategy (State Machine & Fast-Fail)
    pipelineBuilder.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio = 0.5, // 50% failure threshold
        SamplingDuration = TimeSpan.FromSeconds(30),
        MinimumThroughput = 20, // Must have >= 20 requests in 30s before evaluating
        BreakDuration = TimeSpan.FromSeconds(30), // Stay Open for 30s
        Name = "PaymentCircuitBreaker",
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>().HandleResult(IsTransientFailure),
        OnOpened = args =>
        {
            logger.LogError(
                "CIRCUIT BREAKER TRIPPED TO OPEN. Shielding downstream payment service for {BreakDurationSeconds}s. Break reason: {Exception}",
                args.BreakDuration.TotalSeconds,
                args.Outcome.Exception?.Message ?? args.Outcome.Result?.StatusCode.ToString());
            return default;
        },
        OnClosed = args =>
        {
            logger.LogInformation("CIRCUIT BREAKER RESET TO CLOSED. Normal traffic resumed.");
            return default;
        },
        OnHalfOpened = _ =>
        {
            logger.LogWarning("CIRCUIT BREAKER ENTERED HALF-OPEN. Permitting trial probe requests.");
            return default;
        }
    });

    // LAYER 4: Attempt Timeout Strategy (Per-network-attempt budget)
    pipelineBuilder.AddTimeout(new TimeoutStrategyOptions
    {
        Timeout = TimeSpan.FromSeconds(2),
        Name = "AttemptTimeout",
        OnTimeout = args =>
        {
            logger.LogWarning(
                "Individual HTTP network call timed out after {AttemptTimeoutSeconds}s.",
                args.Timeout.TotalSeconds);
            return default;
        }
    });
});

var app = builder.Build();
app.Run();

To understand how inbound request traffic limits protect services upstream before reaching client resilience pipelines, review Rate Limiting in ASP.NET Core: A Beginner-Friendly Guide to All 4 Algorithms.


Telemetry, Distributed Tracing and OpenTelemetry Metrics

Resilience telemetry refers to the runtime generation of structured metrics, distributed trace spans, and diagnostic logs that reveal the operational state, error rates, and latency overhead of resilience strategies.

Polly v8 natively integrates with System.Diagnostics.Metrics. Unlike legacy versions that required custom logging wrappers or third-party adapters, Polly v8 publishes first-class metrics through a built-in Meter named "Polly".

When configuring your observability pipeline with OpenTelemetry in .NET, add the "Polly" meter to your metrics builder:

C#
// Program.cs: OpenTelemetry Metrics and Tracing Setup
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddMeter("Polly") // Subscribes to all Polly v8 internal resilience telemetry
            .AddPrometheusExporter();
    })
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSource("Polly") // Traces pipeline execution steps as activity spans
            .AddOtlpExporter();
    });

Core Metrics Emitted by Polly v8

Polly emits standardized metrics that map cleanly into Grafana dashboards and Prometheus alerting engines:

  1. polly.strategy.execution.duration: A histogram measuring the latency of individual strategy executions, tagged by pipeline.name, strategy.name, and status (Succeeded, Failed).
  2. polly.circuit-breaker.state-changed: A counter recording every transition between Closed, Open, and Half-Open, tagged by pipeline.name and state.
  3. polly.pipeline.execution.duration: A histogram tracking total end-to-end execution latency across the entire pipeline.

Prometheus Alerting Rule: Circuit Breaker Open

To detect production degradation before users report widespread outages, configure a Prometheus alert that triggers when any circuit breaker enters the Open state:

YAML
groups:
  - name: codetoclarity-resilience-alerts
    rules:
      - alert: DownstreamCircuitBreakerOpen
        expr: sum by (pipeline_name) (rate(polly_circuit_breaker_state_changed_total{state="open"}[2m])) > 0
        for: 30s
        labels:
          severity: critical
          team: platform-engineering
        annotations:
          summary: "Circuit breaker {{ $labels.pipeline_name }} is TRIPPED to OPEN"
          description: "Outbound HTTP calls to downstream dependency are being fast-failed. Failure ratio exceeded 50%."

For comprehensive observability setup covering OpenTelemetry traces, distributed logs, and runtime counters, consult The Ultimate Guide to .NET Observability: Traces, Logs, and Metrics.


When NOT to Use Circuit Breakers and Retries: Trade-offs & Production Anti-Patterns

While resilience pipelines protect systems against cascading failures, misapplying retries and circuit breakers introduces severe security, data integrity, and operational risks.

1. Non-Idempotent HTTP Operations (The Double-Charge Hazard)

The most critical rule of retry design is that retries must only execute against idempotent operations. An operation is idempotent if executing it multiple times produces the exact same side-effects on the system as executing it once.

  • Idempotent by specification: HTTP GET, PUT, DELETE, HEAD, OPTIONS. If a GET request fails due to a network glitch, retrying it safely fetches the resource.
  • Non-idempotent by specification: HTTP POST, PATCH. If a client submits a POST /api/v1/orders/charge request and the network socket drops after the remote server processed the credit card but before sending the HTTP 200 response, a blind retry will charge the customer a second time.

Solution: Never configure automatic retries on non-idempotent HTTP methods unless your API passes an Idempotency-Key header verified by the receiver, or decouple the mutation using The Transactional Outbox Pattern: Reliable Event Publishing and Dual-Write Elimination in Distributed Systems.

2. Monolithic In-Memory Method Calls

Wrapping internal in-memory C# method invocations in circuit breakers is an anti-pattern. If a call between two classes within the same process fails, the cause is almost never a transient network partition. It is an unhandled domain exception, an ArgumentNullException, or an invalid business state.

Retrying in-memory exceptions wastes CPU cycles without resolving the bug. Circuit breakers are designed specifically for remote, out-of-process network boundaries where communication traverses a physical wire or socket.

3. The Low-Throughput Service Trap

The Circuit Breaker pattern relies on statistical throughput to make accurate health decisions. If an API endpoint only receives one request every two minutes, a configuration with SamplingDuration = TimeSpan.FromSeconds(30) and MinimumThroughput = 20 will never evaluate a failure ratio. Even if every single request fails for twenty-four hours, the breaker will never trip because the throughput threshold of 20 requests in 30 seconds is never satisfied.

Solution: For low-throughput background services, scale up the SamplingDuration (for example, to fifteen minutes) and reduce MinimumThroughput (for example, to 3 requests).

4. Overly Aggressive Attempt Timeouts

Configuring an Attempt Timeout that is too tight relative to the downstream service's baseline P99 latency causes artificial self-inflicted outages. If a third-party reporting service has a median latency of 400 milliseconds and a P99 latency of 1,200 milliseconds, setting an Attempt Timeout of 500 milliseconds means that 1% of perfectly healthy production calls will be forcibly terminated. This artificial failure rate will cross the 50% threshold under ordinary queueing delay, causing the circuit breaker to trip open on a fully functional service.

Solution: Always inspect production latency histograms in your distributed APM before defining timeout values. Set Attempt Timeouts to at least 2×2\times to 3×3\times the historical P99 latency.

For caching strategies that eliminate downstream network traffic altogether using multi-tiered memory buffers, explore HybridCache in .NET 9: Complete Guide to High-Performance L1/L2 Caching and Stampede Protection.


Production Readiness Checklist

Before shipping Polly v8 resilience pipelines to production Kubernetes clusters, verify your configuration against this operational engineering checklist:

  1. Verify Idempotency: Confirm that retried operations are either naturally idempotent (GET/PUT) or protected by backend idempotency keys.
  2. Validate Strategy Sequence: Ensure the pipeline layers execute from outer to inner: Total Timeout →\rightarrow Retry →\rightarrow Circuit Breaker →\rightarrow Attempt Timeout.
  3. Use Decorrelated Jitter: Replace all fixed and linear backoff algorithms with DelayBackoffType.DecorrelatedJitter.
  4. Tune MinimumThroughput: Confirm that MinimumThroughput aligns with realistic minimum traffic volumes during off-peak hours to prevent false negatives.
  5. Calibrate Cooldowns: Set BreakDuration long enough for downstream services to reboot or clear backlogged queues (typically 30 to 60 seconds).
  6. Filter Business Exceptions: Ensure ShouldHandle predicates only intercept transient status codes (5xx, 429) and network exceptions, letting 4xx client errors pass through cleanly.
  7. Export OpenTelemetry Metrics: Subscribe to the "Polly" meter in your OpenTelemetry setup and configure alerting on polly.circuit-breaker.state-changed.
Kishan Kumar
Written by

Kishan Kumar

Full-Stack .NET Developer & Technical Writer

Software engineer passionate about building scalable, production-grade applications with C#, .NET, and modern cloud technologies. Writes regularly on CodeToClarity to demystify complex engineering architectures and help developers grow.

Related Technical Guides

View all guides →