Circuit Breaker Pattern in .NET

design-patternsdistributed-systemsdotnetpolly
Circuit Breaker Pattern in .NET

A circuit breaker temporarily rejects calls after a dependency's failure rate reaches a configured threshold. In .NET, Polly tracks outcomes across calls, opens the circuit for a break duration, and then allows a trial call to test recovery. Retries and timeouts complement the breaker but need their own limits and failure predicates.

Why Circuit Breakers?

Imagine your service calls a payment API. The API goes down. Without a circuit breaker:

  1. Every request waits for the payment API to respond
  2. Outstanding calls retain memory and occupy connection capacity
  3. Retrying callers increase pressure on the dependency
  4. Latency and resource exhaustion can spread to your service

Asynchronous I/O releases its thread while waiting. Thread pool exhaustion is a separate risk if you block on async calls with .Result or .Wait().

A retry pattern helps with transient failures, but when a service stays down, retrying adds load to an already struggling system.

The circuit breaker solves this by failing fast when it detects the downstream service is unhealthy.

How It Works

The circuit breaker has three states:

Closed (normal): All calls pass through. The circuit breaker monitors success and failure rates.

Open (tripped): After too many failures, the circuit breaker opens. All calls are immediately rejected without attempting the downstream call.

Half-Open (testing): After the break duration, the next call becomes a trial call in Polly. Other callers remain blocked while that trial runs; its outcome determines whether the breaker closes or reopens.

Circuit breaker state machine: Closed trips to Open when the failure threshold is exceeded, Open moves to Half-Open after the break duration, and a trial call in Half-Open either closes the circuit on success or reopens it on failure

Implementation With Polly

Create a .NET 10 web project with dotnet new web -n CircuitDemo and install the HTTP resilience package, which also brings in Polly's dependency-injection integration:

dotnet add package Microsoft.Extensions.Http.Resilience

Use these namespaces in Program.cs:

using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.CircuitBreaker;
using Polly.Registry;
using Polly.Retry;
using Polly.Timeout;

Register a shared Polly pipeline before builder.Build():

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResiliencePipeline("payment-api", pipeline =>
{
    pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,                             // Open after 50% failure rate
        SamplingDuration = TimeSpan.FromSeconds(30),     // Measure over 30-second windows
        MinimumThroughput = 10,                          // Need at least 10 calls to evaluate
        BreakDuration = TimeSpan.FromSeconds(30),        // Stay open for 30 seconds
        ShouldHandle = new PredicateBuilder()
            .Handle<HttpRequestException>(TransientHttp.IsFailure)
            .Handle<TimeoutRejectedException>()
    });
    pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});

Use this predicate when an HTTP method such as GetFromJsonAsync converts status errors into exceptions:

public static class TransientHttp
{
    public static bool IsFailure(HttpRequestException exception) =>
        exception.StatusCode is null or HttpStatusCode.RequestTimeout
            or HttpStatusCode.TooManyRequests ||
        (int)exception.StatusCode.Value >= 500;
}

The breaker records failures and rethrows them; it does not retry or substitute a result. Resolve and reuse this pipeline through the registry so its state survives individual calls.

Parameters explained:

  • FailureRatio: the percentage of failed calls that triggers the circuit breaker
  • SamplingDuration: the time window over which failures are counted
  • MinimumThroughput: minimum number of calls before the ratio is evaluated (prevents opening on a single failure)
  • BreakDuration: how long the circuit stays open before moving to half-open

Circuit Breaker for HTTP Clients

The most common use case is protecting HTTP calls to external APIs:

builder.Services.AddHttpClient("PaymentApi", client =>
{
    client.BaseAddress = new Uri("https://payments.example.com");
})
.AddResilienceHandler("circuit-breaker", pipeline =>
{
    pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,
        SamplingDuration = TimeSpan.FromSeconds(30),
        MinimumThroughput = 10,
        BreakDuration = TimeSpan.FromSeconds(60)
    });
    pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});

Or use the standard resilience handler that includes retry + circuit breaker + timeout:

builder.Services.AddHttpClient("PaymentApi")
    .AddStandardResilienceHandler(options =>
        options.Retry.DisableForUnsafeHttpMethods());

Choose one registration for a given client. The standard handler retries all HTTP methods by default, so the example disables retries for unsafe methods until their side effects have an explicit idempotency contract. Its breaker observes HTTP responses, including 408, 429, and 5xx, without requiring EnsureSuccessStatusCode().

I covered how to customize these defaults in overriding default HTTP resilience handlers in .NET.

Combining Retry and Circuit Breaker

Strategies execute in the order you add them, first added outermost. Put retry outside the circuit breaker when you want the breaker to count each attempt:

builder.Services.AddResiliencePipeline("external-api", pipeline =>
{
    pipeline.AddTimeout(TimeSpan.FromSeconds(20));

    // Retry (outer) - handles transient failures with backoff
    pipeline.AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        Delay = TimeSpan.FromMilliseconds(500),
        UseJitter = true,
        ShouldHandle = new PredicateBuilder()
            .Handle<HttpRequestException>(TransientHttp.IsFailure)
            .Handle<TimeoutRejectedException>()
    });

    // Circuit breaker - stops calls when the service is down
    pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,
        SamplingDuration = TimeSpan.FromSeconds(30),
        MinimumThroughput = 10,
        BreakDuration = TimeSpan.FromSeconds(30),
        ShouldHandle = new PredicateBuilder()
            .Handle<HttpRequestException>(TransientHttp.IsFailure)
            .Handle<TimeoutRejectedException>()
    });

    // Timeout (innermost) - per-attempt timeout
    pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});

The flow:

  1. The retry strategy allows the original attempt plus up to three retries with backoff and jitter
  2. Each attempt passes through the circuit breaker; an open circuit throws BrokenCircuitException, which this retry predicate excludes
  3. Each attempt that reaches the network has a 5-second timeout
  4. The outer timeout caps the entire operation, including backoff, at 20 seconds

This ordering is what .NET's own AddStandardResilienceHandler uses. When the circuit opens, the current operation stops as soon as an attempt encounters it. If retry goes inside the breaker, the breaker instead records one final outcome per logical operation. That is a different measurement boundary, and can be appropriate when you want to count operations rather than individual attempts.

Handling the Open Circuit

For a runnable read-only example, use the payment-api pipeline above and add this registration and endpoint before the type declarations in Program.cs:

builder.Services.AddHttpClient("PaymentStatus", client =>
    client.BaseAddress = new Uri("https://payments.example.com"));

var app = builder.Build();
app.MapGet("/payments/{id:guid}", async (
    Guid id, IHttpClientFactory clients,
    ResiliencePipelineProvider<string> pipelines, CancellationToken ct) =>
{
    var pipeline = pipelines.GetPipeline("payment-api");
    var client = clients.CreateClient("PaymentStatus");
    try
    {
        var result = await pipeline.ExecuteAsync(async token =>
        {
            return await client.GetFromJsonAsync<PaymentStatus>(
                $"/api/payments/{id}", token)
                ?? throw new InvalidOperationException("Empty payment response.");
        }, ct);
        return Results.Ok(result);
    }
    catch (BrokenCircuitException)
    {
        return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
    }
});
app.Run();

public sealed record PaymentStatus(Guid Id, string Status);

This endpoint maps an open circuit to 503; use your normal exception handling for other upstream errors and timeouts. A fallback may return cached data when the contract allows it. Never claim that a payment was queued unless you have durably queued it, and never retry a payment POST without server-side idempotency.

Listening for State Changes

Monitor state transitions when configuring the breaker. For example, with an ILogger named logger available in the registration callback:

pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
    FailureRatio = 0.5,
    SamplingDuration = TimeSpan.FromSeconds(30),
    MinimumThroughput = 10,
    BreakDuration = TimeSpan.FromSeconds(30),
    ShouldHandle = new PredicateBuilder()
        .Handle<HttpRequestException>(TransientHttp.IsFailure)
        .Handle<TimeoutRejectedException>(),
    OnOpened = args =>
    {
        logger.LogWarning(
            "Circuit opened for {Duration}. Reason: {Reason}",
            args.BreakDuration,
            args.Outcome.Exception?.Message);
        return ValueTask.CompletedTask;
    },
    OnClosed = _ =>
    {
        logger.LogInformation("Circuit closed - service recovered");
        return ValueTask.CompletedTask;
    },
    OnHalfOpened = _ =>
    {
        logger.LogInformation("Circuit half-opened - testing recovery");
        return ValueTask.CompletedTask;
    }
});

Use these events for alerting and dashboards.

Circuit Breaker vs Retry

The two patterns answer different failure modes:

  • Retry handles transient, momentary failures. It repeats the same call after a brief delay. It works well for a blip in connectivity, but blind retries can overwhelm a service that's already struggling.
  • Circuit breaker handles prolonged outages. It stops calling the failing service entirely for a period. It helps most when the dependency stays down, at the risk of rejecting calls slightly too aggressively during recovery.

They complement each other, which is why the pipeline above uses both. I've written a dedicated comparison in retry vs circuit breaker if you're deciding which to reach for first.

Practical Configuration Tips

  1. Set MinimumThroughput high enough. A single failed call shouldn't open the circuit. Use 10-20 as a starting point.

  2. Match BreakDuration to your recovery time. If the downstream service typically recovers in 30 seconds, set the break to 30-60 seconds.

  3. Monitor your circuits. Use OpenTelemetry or logs to track when circuits open and close. Frequent opens indicate a reliability problem.

  4. Use per-service circuit breakers. Don't share one circuit breaker across all calls. A failing payment service shouldn't break your email service.

  5. Combine with health checks. If a circuit opens frequently, the health check can mark the service as degraded.

Summary

The circuit breaker pattern prevents cascading failures by failing fast when a downstream service is unhealthy.

Reuse a breaker per dependency, count only the failures relevant to that dependency, and monitor state transitions.

Start with the standard HTTP resilience handler for HTTP clients and configure which methods may be retried. For the bigger picture of resilience in cloud systems, see building resilient cloud applications with .NET.

Frequently Asked Questions

What problem does the circuit breaker pattern solve?

It temporarily rejects calls to a dependency with a high recent failure rate. That reduces outstanding requests and repeated downstream work during an outage. Asynchronous HTTP calls do not occupy a thread while waiting, but they still retain request state and consume other resources.

What are the three states of a circuit breaker?

Closed (calls flow normally while failures are counted), Open (calls are rejected immediately after the failure threshold trips), and Half-Open (after the break duration, a trial call tests whether the dependency has recovered).

Should retry go inside or outside the circuit breaker?

Place retry outside when the breaker should count each attempt, as .NET's standard resilience handler does. Exclude BrokenCircuitException from the retry predicate so an open circuit ends the operation immediately. An outer breaker instead measures the final outcome of each logical operation.

What is the difference between retry and circuit breaker?

Retry handles brief transient failures by attempting the call again. A circuit breaker handles sustained outages by stopping calls entirely for a period. They complement each other and are typically combined in one pipeline.

How do I add a circuit breaker to HttpClient in .NET?

Use Microsoft.Extensions.Http.Resilience: call AddStandardResilienceHandler on the HttpClient registration to get retry, circuit breaker, and timeouts preconfigured, or AddResilienceHandler to compose your own pipeline.

  • Retry Pattern in .NET With Polly

    Polly retries transient failures with a bounded number of attempts, backoff, and jitter. This guide wires a retry pipeline into a .NET HTTP client, explains which failures to handle, and shows how to avoid duplicate writes and retries that multiply across layers.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

  • Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

    Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.