Circuit Breaker in C# .NET 10 — Interactive Guide
A circuit breaker stops your app from hammering a dependency that is already failing. It fails fast, gives the dependency room to recover, and probes carefully before letting traffic back. This guide takes you from zero to production: the states, the exact math Polly uses, a live simulator you can break, a playground that writes the C# for you, and twelve real-world scenarios.
01 · The problem
Why circuit breakers exist
In a distributed system your code calls other things over the network: payment gateways, databases, caches, other microservices, AI APIs. Most failures are transient — a dropped packet, a brief overload — and a quick retry fixes them.
Some failures last longer: a crashed service, a locked database, an expired certificate, a regional outage. Retrying those just adds load to something that is already on fire. Worse, each waiting call holds a thread, a socket, a DB connection and memory. When enough calls are stuck, your service runs out of resources and starts failing too — even for features that never touch the broken dependency. That chain reaction is a cascading failure.
A circuit breaker sits in front of the dependency like a proxy. It watches recent outcomes; when too many fail it opens and rejects calls instantly for a while, then lets a single probe through to see if things recovered.
Thread-pool view of an outage
One request arrives every tick. From tick 12 the dependency hangs until a 16-tick timeout. Each square is a worker thread.
Without a circuit breaker
With a circuit breaker
■ working · ■ stuck waiting on the broken dependency · empty = free
Circuit breaker vs. its neighbours
Polly groups strategies into reactive (react to a failure that already happened) and proactive (prevent one). A circuit breaker is reactive. It is not a retry: it does not retry anything and it re-throws the original failures while closed. You combine them.
| Strategy | Type | Question it answers | Typical use |
|---|---|---|---|
| Retry | Reactive | Was that a blip? Try again. | Short network glitches, 503 during deploys |
| Circuit breaker | Reactive | Is it broken? Stop calling for a while. | Outages lasting seconds to minutes |
| Timeout | Proactive | Is it taking too long? Give up. | Turning hangs into countable failures |
| Fallback | Reactive | It failed — what do we return instead? | Cached data, default value, queue for later |
| Rate limiter (bulkhead) | Proactive | Are we sending too much at once? | Quotas, protecting a fragile dependency |
| Hedging | Reactive | Slow? Fire a parallel attempt and take the first. | Latency-critical reads across replicas |
02 · State machine
The four states
Polly's breaker is a small state machine. Three states are automatic; the fourth, Isolated, is a manual override. Pick a state to see what happens to calls, what is recorded, and which callback and telemetry event fire.
- Closed → Open: in the sampling window, failures ÷ calls ≥
FailureRatioand calls ≥MinimumThroughput. - Open → Half-Open:
BreakDurationhas elapsed and a new call arrives (transitions are lazy). - Half-Open → Closed: the probe call succeeds; statistics reset.
- Half-Open → Open: the probe fails; a new break starts.
- Any → Isolated:
ManualControl.IsolateAsync(). - Isolated → Closed:
ManualControl.CloseAsync().
03 · The decision rule
How Polly decides to break
Polly v8 has one circuit breaker, the “advanced” ratio-based one. Four options control it:
SamplingDuration
The rolling time window over which outcomes are counted. Default 30 s. Old outcomes slide out continuously (Polly keeps the window as small buckets).
MinimumThroughput
How many calls must be in the window before the ratio is even considered. Default 100. Below it, the circuit never breaks — even at 100% failures.
FailureRatio
The fraction of handled failures that trips the breaker. Default 0.1 (10%). Polly breaks when the ratio reaches the threshold.
BreakDuration
How long the circuit stays Open before the next call may probe. Default 5 s. Or compute it with BreakDurationGenerator (wins if both are set).
calls in window ≥ MinimumThroughput and failures ÷ calls ≥ FailureRatio → the circuit opens. What counts as a failure is decided by ShouldHandle (default: every exception except OperationCanceledException). Anything not handled counts as a success.Try the rule
Things beginners miss
- A handled result is still returned. If you handle
HttpResponseMessagewith status 500, the breaker counts it but your code still receives the 500 while the circuit is closed. The breaker re-throws / returns every outcome; it only blocks new calls once open. - Open means “not executed”. While open, your delegate is never invoked; Polly throws
BrokenCircuitException(withRetryAfter). - Transitions are lazy. The breaker is not a background timer. It moves to Half-Open only when a call arrives after the break.
- One probe at a time. In Half-Open Polly lets a single call through; other calls arriving during the probe are still rejected.
- State lives in the pipeline instance. Build the pipeline once and reuse it (singleton or DI). A new pipeline per request means a new, always-closed breaker.
- State is per process. Ten pods have ten independent breakers. That's normal and usually desirable.
04 · Hands on
Live state simulator
This runs the same algorithm as Polly's breaker. Make the service fail, slow it down past the 1 s attempt timeout, or isolate it by hand, and watch the state, the sampling window and the event log. Values are demo-friendly; Polly's defaults are 10% · 100 calls · 30 s · 5 s.
Reduced motion is on: the simulator starts paused and draws no moving dots. Press Play to run it calmly.
Failure ratio in window 0% / 50%
Throughput in window 0 / 10
What the next call gets
Event log (Polly telemetry names)
05 · Tune it
Config playground
Choose a traffic level and a failure story, then tune the four knobs. The chart replays 90 seconds instantly so you can compare settings, spot breakers that can never open, and copy ready-to-use C# for Polly or the HttpClient standard handler.
06 · Library deep dive
Polly v8 — the ResiliencePipeline API
Polly v8 (current release 8.8.0) replaced v7's policies with resilience pipelines. You build a pipeline from strategies, keep it, and execute delegates through it. Use this directly for anything that isn't an HttpClient call: databases, Redis, gRPC, SDKs, message brokers, file shares.
1 · Your first breaker
Create the pipeline once (it holds the circuit state), then run calls through it. Catch BrokenCircuitException to react to an open circuit.
2 · Defaults you get with new CircuitBreakerStrategyOptions()
| Property | Default | Meaning |
|---|---|---|
ShouldHandle | Any exception except OperationCanceledException | Which exceptions/results count as failures |
FailureRatio | 0.1 | 10% of sampled calls failing trips the circuit |
MinimumThroughput | 100 | Calls needed in the window before the ratio matters |
SamplingDuration | 30 s | Rolling window for the ratio |
BreakDuration | 5 s | Fixed time Open before probing |
BreakDurationGenerator | null | Compute the break at runtime (overrides BreakDuration) |
ManualControl | null | Isolate / close the circuit from code |
StateProvider | null | Read the current state (health checks, dashboards) |
OnOpened / OnClosed / OnHalfOpened | null | Callbacks on state transitions |
3 · Handling results, not just exceptions
Use the generic options to treat certain results (like HTTP 5xx) as failures. Remember: the breaker counts them, but your code still receives them while the circuit is closed.
4 · Callbacks for logging and alerts
Polly also emits telemetry for every transition (via Polly.Extensions), so you rarely need to log by hand:
| Event name | Severity | When |
|---|---|---|
OnCircuitOpened | Error | Just before OnOpened runs |
OnCircuitHalfOpened | Warning | Just before OnHalfOpened runs (result is always empty) |
OnCircuitClosed | Information | Just before OnClosed runs |
5 · Dynamic break duration
A fixed 5 s break can be too eager for a service that needs minutes to recover. BreakDurationGenerator receives runtime information such as FailureCount, FailureRate and HalfOpenAttempts, so you can back off exponentially between probes.
6 · Reading and controlling state
CircuitBreakerStateProvider reads the state; CircuitBreakerManualControl forces it. Isolated calls throw IsolatedCircuitException, which derives from BrokenCircuitException.
StateProvider.CircuitState and skip the call when it's Open. The breaker only moves to Half-Open when a call is executed, so a guard like that keeps the circuit open forever. Execute and catch instead — or use ExecuteOutcomeAsync to avoid exceptions.7 · No-throw execution on hot paths
ExecuteOutcomeAsync captures exceptions in an Outcome<T> instead of throwing — cheaper when the circuit is open and thousands of calls per second are rejected.
8 · Strategy order: who wraps whom
Strategies execute from the first added (outermost) to the last added (innermost). The usual order around a breaker:
9 · Dependency injection with AddResiliencePipeline
With Polly.Extensions, register pipelines by key and resolve them from ResiliencePipelineProvider<string>. The provider caches the instance, so the circuit state is shared app-wide — exactly what you want.
10 · Passing data between strategies with ResilienceContext
Polly's docs recommend sharing information through the context rather than coupling strategies via a state provider. Here the breaker publishes its break duration and the retry waits that long:
11 · Unit testing without waiting
Pipelines accept a TimeProvider. With FakeTimeProvider you can jump past the break duration instantly. Polly.Testing lets you inspect a built pipeline's strategies and options.
12 · Coming from Polly v7?
| Polly v7 | Polly v8 |
|---|---|
Policy.Handle<T>().AdvancedCircuitBreakerAsync(0.5, 10s, 8, 30s) | AddCircuitBreaker(new() { FailureRatio = 0.5, SamplingDuration = 10s, MinimumThroughput = 8, BreakDuration = 30s }) |
CircuitBreakerAsync(5, 30s) (consecutive failures) | No direct equivalent. Approximate with FailureRatio = 1.0, MinimumThroughput = 5 and a short SamplingDuration |
onBreak / onReset / onHalfOpen | OnOpened / OnClosed / OnHalfOpened |
policy.CircuitState | CircuitBreakerStateProvider.CircuitState |
policy.Isolate() / policy.Reset() | CircuitBreakerManualControl.IsolateAsync() / CloseAsync() |
Microsoft.Extensions.Http.Polly, Polly.Extensions.Http | Deprecated → Microsoft.Extensions.Http.Resilience |
07 · HttpClient integration
Microsoft.Extensions.Http.Resilience
Microsoft's official wrapper around Polly v8 for IHttpClientFactory (current release 10.11.0, works with .NET 10). It plugs a resilience pipeline into the HttpClient handler chain, so every request goes through it automatically — no ExecuteAsync calls in your code. It also wires up DI, options binding, logging and metrics.
1 · The standard resilience handler
One line gives you five strategies, chained from outermost to innermost. Step through a failing request to see how they cooperate:
Step 0 / 9
Press Next to send a GET request while the remote API is returning 503.
2 · What the standard handler treats as failure
Both the retry and the circuit breaker handle:
- HTTP 500 and above (server errors), 408 Request Timeout, 429 Too Many Requests
HttpRequestExceptionand Polly'sTimeoutRejectedException(notSystem.TimeoutException)
A 400, 401, 403 or 404 is your problem, not the server's health — it is not counted. Retries are enabled for all HTTP methods by default; turn them off for unsafe ones.
3 · Customising the circuit breaker in the standard handler
SamplingDuration to be at least double the attempt timeout (defaults: 30 s vs 10 s). Raise the attempt timeout to 20 s and you must raise sampling to ≥ 40 s, or startup validation fails.4 · Settings from appsettings.json
5 · One breaker per host: SelectPipelineByAuthority
By default a named or typed client gets one pipeline — one circuit — no matter which host you call. If a single client talks to many independent hosts, one bad host would open the circuit for all of them. Select pipelines by authority (scheme + host + port) to get a breaker per host.
6 · Fully custom pipeline with AddResilienceHandler
Need a different order, a fallback, or your own strategies? Build the pipeline yourself. HttpCircuitBreakerStrategyOptions already handles the transient HTTP failures listed above.
7 · App-wide defaults, with exceptions
Add only one resilience handler per client — stacking handlers multiplies retries and breakers. Use RemoveAllResilienceHandlers to replace a default.
8 · Standard hedging handler: a breaker per endpoint
Hedging fires extra attempts against other endpoints when one is slow. It keeps a pool of circuit breakers, one per endpoint (by authority), so it never hedges against an endpoint whose circuit is open. Its pipeline: total timeout 30 s → hedging (up to 10 attempts, 2 s delay) → per-endpoint rate limiter → per-endpoint circuit breaker → per-endpoint attempt timeout 10 s.
9 · Handling an open circuit in your client and API
When the circuit is open, HttpClient calls throw BrokenCircuitException — not HttpRequestException. Catch it where you can degrade, and map it to 503 + Retry-After for your own callers.
10 · Dynamic reload and known issues
- Dynamic reload: use the
AddResilienceHandleroverload that gives you aResilienceHandlerContextand callcontext.EnableReloads<HttpCircuitBreakerStrategyOptions>("name"); when the bound configuration changes, the pipeline is rebuilt. - gRPC: with
Grpc.Net.ClientFactory2.63.0 or earlier, the standard handlers can throw at runtime; use 2.64.0 or later. - Application Insights: versions 2.22.0 and older can lose telemetry if resilience is registered first; upgrade to 2.23.0+ or register Application Insights before resilience.
- Static clients: for a singleton
HttpClientwithout DI, build aResilienceHandler/ResiliencePipelineyourself — the breaker still needs to be created once and reused.
08 · In production
Real-world scenario explorer
Twelve situations teams actually hit. Pick one, then step through the timeline to see the circuit state, what the caller does, and the C# that implements it.
How to build it in C#
09 · Judgement
When to use it — and when not to
Use it
- Calls cross a process or network boundary: HTTP/gRPC services, databases, caches, brokers, third-party SaaS, AI APIs.
- The dependency can be down or degraded for seconds to minutes, not just milliseconds.
- Waiting calls consume scarce resources (threads, sockets, connection pools, memory) and could cascade.
- You want to protect latency SLOs from a slow dependency — slow is the new down.
- You have something useful to do instead: cached data, a default, a queue, an alternative provider, a clear error.
- Each call costs money or quota (paid APIs, LLM tokens, rate-limited SaaS).
- You need a real-time failure signal to route traffic, e.g. between regions or providers.
Skip it
- In-process work: in-memory data structures, pure computation, local file parsing. A breaker only adds overhead.
- As a substitute for business-logic error handling: validation errors, 400/404, “insufficient funds” are not outages.
- Simple retries are enough and the dependency is built to absorb them.
- Waiting for a reset would cause unacceptable delays and there is no alternative path anyway.
- Message- or event-driven flows where queue retries and dead-letter queues already isolate failures.
- The platform already does it for this call: a service mesh (Envoy/Istio outlier detection), an API gateway policy, or load-balancer health probes. Avoid double layers with conflicting thresholds.
- Traffic is so low that ratios are meaningless — unless you tune
MinimumThroughputandSamplingDurationfor it.
Quick decision helper
10 · Experience
Pitfalls & best practices
Counting the wrong failures
Handling every exception, including 400/404 and validation errors — a single buggy client request pattern opens the circuit.
Handle only transient, server-side signals: 5xx, 408, 429, HttpRequestException, TimeoutRejectedException, driver-specific connection errors.
A new pipeline per call
Building ResiliencePipeline inside the method or a transient service — each call gets a fresh, closed breaker that never trips.
Build once: a static field, a singleton, AddResiliencePipeline or AddResilienceHandler.
One breaker for many dependencies
Sharing one breaker across independent hosts, shards or tenants — one bad apple blocks them all.
One breaker per independent dependency: separate clients/keys, or SelectPipelineByAuthority().
Timeouts that are too long
Relying on HttpClient.Timeout's 100 s default — the breaker sees nothing until calls finally fail, while threads pile up.
Add an attempt timeout inside the breaker that matches the dependency's normal p99 latency plus headroom.
Retry in the wrong place
Retry inside the breaker, or a retry that blindly retries BrokenCircuitException with tiny delays.
Retry outside the breaker; don't retry BrokenCircuitException, or wait at least its RetryAfter.
Not handling the open state
Letting BrokenCircuitException bubble up as a generic 500.
Catch it where you can degrade; return 503 with a Retry-After header otherwise.
Guarding with the state provider
if (state != Open) Execute(...) — the circuit can never reach Half-Open.
Always execute through the pipeline; use ExecuteOutcomeAsync if exceptions are too costly.
Thresholds that can't trigger
Defaults (100 calls / 30 s) on an endpoint that gets 10 calls a minute.
Make sure rps × SamplingDuration comfortably exceeds MinimumThroughput. Test it in the playground.
Retrying non-idempotent writes
The standard handler retries POST by default — a timed-out payment may be charged twice.
options.Retry.DisableForUnsafeHttpMethods(), idempotency keys, an outbox.
Stacking handlers
AddStandardResilienceHandler() on top of app defaults plus your own handler — retries multiply (3 × 3 × 3).
One handler per client; RemoveAllResilienceHandlers() before adding a custom one.
Flying blind
No alert when a circuit opens; nobody notices until users complain.
Export the Polly meter to OpenTelemetry, alert on OnCircuitOpened, show state on a dashboard.
Breaking your own liveness
Failing the liveness probe when a downstream circuit is open — Kubernetes restarts healthy pods.
Report Degraded on readiness/health; never kill the process for someone else's outage.
Testing with real time
Thread.Sleep(30_000) in unit tests to wait out a break.
FakeTimeProvider + ResiliencePipelineBuilder.TimeProvider; chaos/fault injection in staging.
Forgetting where the load goes
Falling back from a dead cache straight to the database at full traffic.
Protect the fallback path too (rate limiter, concurrency limit, smaller page sizes).
11 · Reference
Glossary & cheat sheet
| Setting | Polly CircuitBreakerStrategyOptions | Standard HTTP handler | Starting point to tune from |
|---|---|---|---|
| FailureRatio | 0.1 | 0.1 | 0.1–0.5 depending on noise |
| MinimumThroughput | 100 | 100 | ≈ 20–50% of calls per window |
| SamplingDuration | 30 s | 30 s (≥ 2 × attempt timeout) | Long enough for a stable ratio |
| BreakDuration | 5 s | 5 s | Typical recovery time; grow with a generator |
| Handles | Any exception except cancellation | 5xx, 408, 429, HttpRequestException, TimeoutRejectedException | Only transient, server-side failures |
| Exception when open | BrokenCircuitException (IsolatedCircuitException when isolated) with RetryAfter and TelemetrySource | Catch, degrade, or return 503 | |
- Transient fault
- A short-lived failure that fixes itself (packet loss, brief overload). Retry territory.
- Cascading failure
- One failing component exhausts resources in its callers, which then fail their callers.
- Fail fast
- Reject immediately instead of waiting for a call that will probably fail.
- Short-circuit
- A call the open breaker rejected without executing your delegate.
- Sampling window
- The rolling
SamplingDurationover which calls and failures are counted. - Throughput
- Number of calls in the window. Must reach
MinimumThroughputfor the ratio to count. - Break / trip
- Closed → Open. “The circuit broke.”
- Probe
- The single trial call allowed in Half-Open to test recovery.
- ShouldHandle
- Predicate deciding which exceptions/results are failures. Built with
PredicateBuilder. - RetryAfter
- Property on
BrokenCircuitException: at least how long the circuit stays open. - ResiliencePipeline
- Polly v8's composable chain of strategies; holds the breaker state.
- ResilienceContext
- Per-execution bag (cancellation token, operation key, properties) shared by strategies.
- Fallback
- What you return instead of failing: cache, default, alternative provider.
- Bulkhead / rate limiter
- Caps concurrency so one dependency can't consume every resource.
- Hedging
- Parallel attempts against alternatives when the first is slow; uses per-endpoint breakers in the standard hedging handler.
- Idempotent
- Safe to repeat with the same effect (GET, PUT with keys). Retry only these automatically.
- Authority
- Scheme + host + port of a URL.
SelectPipelineByAuthoritykeys breakers by it. - Graceful degradation
- Keeping the core experience working while a non-critical feature is off.