Circuit Breaker Pattern
Overview
Suppose an order service calls an exchange-rate API to calculate payment amounts. If that API stops responding, a call fails only after its timeout, say two seconds. During an outage lasting several minutes, every incoming request waits those two seconds while holding threads and connections, and the service keeps calling an unresponsive API. Adding retries only increases the calls generated by each request.
A circuit breaker wraps dependency calls and counts recent failures. Once they reach a configured threshold, it stops invoking the dependency for a while and immediately fails new calls. Reporting failure without waiting for a timeout is called failing fast. The name comes from electrical circuit breakers; Microsoft's documentation likewise explains the three states as imitating an electrical breaker's behavior.1
The breaker moves among three states. Closed is normal operation: pass calls to the dependency and count failures. Open rejects calls immediately without forwarding them. Once its open duration has elapsed, the breaker becomes Half-Open and uses a trial call to check recovery. A successful trial closes the breaker; a failed trial opens it again. In this article's example, the exchange-rate API is the dependency, three consecutive failures are the threshold, and the open duration is 30 seconds.
Structure
The lower compartments of Closed and Open show internal transitions that leave the state unchanged. Azure's description moves to Half-Open when an open timer expires,1 but this article's code has no timer, as in Polly. The first call arriving after the open duration changes the state to HalfOpen and itself becomes the trial call.2
failure means an outcome that the policy counts as a failure. An uncounted error, such as InvalidRequest in the extended example, causes no transition in any state. In HalfOpen, such an error leaves the state unchanged and the next call is another trial.
History
Michael Nygard covered Circuit Breaker in Release It!, published by Pragmatic Bookshelf in 2007. The publisher's table of contents opens Chapter 5, Stability Patterns, with 5.1 Use Timeouts, 5.2 Circuit Breaker and 5.3 Bulkheads.3 The second edition, published in January 2018, also places Circuit Breaker in its Stability Patterns chapter alongside Timeouts, Bulkheads and Fail Fast.4
In his 2014 bliki article CircuitBreaker, Martin Fowler credits Nygard with popularizing the pattern for preventing cascading failures, and illustrates it in Ruby. Reaching the failure threshold trips the breaker and opens the circuit. Later calls receive an error without invoking the protected operation. After the reset interval, an arriving Half-Open call becomes a trial: success resets the breaker, failure starts another wait. Fowler's example increments its failure counter for each timeout and resets it to zero on success.5
Microsoft's Azure Architecture Center describes a breaker as a proxy for operations that may fail: an object that receives and forwards calls on their behalf. In its description, the Closed-state failure counter is time-based and periodically reset; the configured number of failures must occur within a specified interval to open the circuit. Half-Open admits only a limited number of requests so a recovering service does not face a sudden surge.1
Netflix's Hystrix was a latency and fault tolerance library that isolated access points to remote systems and supplied circuit breakers alongside isolation. Its README says it is in maintenance mode, with no active development and 1.5.18 as its final release, and that new internal projects would use actively developed alternatives such as resilience4j.6 resilience4j's CircuitBreaker calculates failure and slow-call rates through sliding windows over either the last N calls or the calls from the last N seconds.7
Why use it?
While the circuit is open, callers receive an outcome immediately instead of waiting for a timeout. Azure explains that requests blocked on timeouts hold memory, threads and database connections, potentially causing unrelated features to fail.8 New calls no longer wait while holding those resources, and the failed dependency gets time to recover under reduced traffic. Timeouts and cancellation handle calls already in progress. Half-Open admits only trials, avoiding a sudden return to normal load on a newly recovered dependency.
State transitions also reveal the dependency's condition. Fowler recommends logging every breaker state change and exposing its state for deeper monitoring.5
The breaker does not repair the dependency, and callers must decide what to do with rejected calls. Azure lists temporarily reducing functionality, obtaining the same data through another operation, or asking the user to try later. Thresholds and open duration also need tuning. Staying open too long keeps rejecting after recovery; moving to Half-Open too soon causes frequent state changes.9
Which outcomes count as failures also needs a decision. Fowler notes that some errors are normal failures handled by application logic and should not open the circuit.5 A malformed-request error shows that the dependency is alive and responding, so it is not counted. Breaker scope matters as well: one breaker spanning independent targets, such as separate shards, may let one target's outage block healthy targets.9
Azure says a breaker adds overhead for resources accessed within an application, such as in-memory data structures, and should not replace exception handling in business logic.10
Polly provides a circuit-breaker strategy for .NET. With its defaults, a 30-second sampling window (SamplingDuration) must contain at least 100 executions (MinimumThroughput). The circuit opens when the failure ratio reaches 0.1 (FailureRatio), and stays open for five seconds (BreakDuration). Calls not executed while it is open receive a BrokenCircuitException.2 Microsoft.Extensions.Resilience and Microsoft.Extensions.Http.Resilience build on Polly; AddCircuitBreaker adds this strategy to their pipeline.11 The examples here implement only the state transitions directly, without a package.
Combining retries and timeouts
Retry Pattern repeats a call on the assumption that a transient failure may clear soon. A circuit breaker withholds calls likely to fail. When combining them, Azure says retries should call through the breaker and stop if the breaker reports a failure that is not transient.1 Failed retry attempts passing through the breaker count as failures too, bringing the opening point closer. Polly's breaker performs no retries itself; it is combined with the Retry strategy,2 and Microsoft.Extensions.Resilience executes strategies in the order they are added, so that order must be chosen deliberately.11 The standard Microsoft.Extensions.Http.Resilience handler stacks a rate limiter, total timeout, retry, circuit breaker and attempt timeout from outside to inside. Retries wrap the breaker, while the attempt timeout sits inside it.12
A breaker knows whether a call succeeded only when it completes. With a long timeout on an unresponsive dependency, many calls may hold threads before the breaker can count their failures.9 Use a Timeout pattern to turn slow calls into failures within a defined interval, which the breaker can then count. The standard handler above likewise places the attempt timeout inside the breaker, and blocks execution when too many direct failures or timeouts are detected.12 Some implementations, such as resilience4j, separately count calls exceeding a configured duration (slowCallDurationThreshold) as slow calls and open the circuit using another threshold.7
Example
Wrap an exchange-rate API with a breaker that opens after three consecutive failures and stays open for 30 seconds. The fake API fails its first three calls with timeout and succeeds afterward. Time is injected through a TimeProvider subclass named ManualClock. TimeProvider is .NET's time abstraction.13 ManualClock overrides only GetUtcNow, and the breaker reads time only through GetUtcNow. Time therefore moves only when Advance is called; the example never waits 30 real seconds. A CallResult distinguishes success, failure and rejection in the returned value.
using System;
using System.Collections.Generic;
var clock = new ManualClock();
var api = new FakeRatesApi(failFirst: 3);
var breaker = new CircuitBreaker(clock, failureThreshold: 3, openDuration: TimeSpan.FromSeconds(30));
Call(1);
Call(2);
Call(3);
Call(4);
clock.Advance(TimeSpan.FromSeconds(10));
Call(5);
clock.Advance(TimeSpan.FromSeconds(20));
Call(6);
Call(7);
Console.WriteLine($"api calls: {api.Calls}");
void Call(int n)
{
CallResult result = breaker.Execute(api.GetRate);
Console.WriteLine($"t={clock.Seconds}s call {n}: {result}");
}
enum CircuitState { Closed, Open, HalfOpen }
enum Outcome { Ok, Failed, Rejected }
readonly record struct CallResult(Outcome Outcome, string Detail)
{
public override string ToString() => $"{Outcome} ({Detail})";
}
// Test clock: override only GetUtcNow; its value changes only through Advance.
sealed class ManualClock : TimeProvider
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private DateTimeOffset now = Start;
public override DateTimeOffset GetUtcNow() => now;
public void Advance(TimeSpan by) => now += by;
public int Seconds => (int)(now - Start).TotalSeconds;
}
// A fake exchange-rate API: timeout for the first failFirst calls, then success.
sealed class FakeRatesApi(int failFirst)
{
public int Calls { get; private set; }
public CallResult GetRate()
{
Calls++;
return Calls <= failFirst
? new CallResult(Outcome.Failed, "timeout")
: new CallResult(Outcome.Ok, "USD/KRW 1380");
}
}
sealed class CircuitBreaker(TimeProvider clock, int failureThreshold, TimeSpan openDuration)
{
private int consecutiveFailures;
private DateTimeOffset openedAt;
public CircuitState State { get; private set; } = CircuitState.Closed;
public CallResult Execute(Func<CallResult> operation)
{
// After openDuration, enter HalfOpen and send this call as the trial.
if (State == CircuitState.Open && clock.GetUtcNow() - openedAt >= openDuration)
{
MoveTo(CircuitState.HalfOpen, "open duration elapsed");
}
// While open, reject immediately without invoking the dependency.
if (State == CircuitState.Open)
{
return new CallResult(Outcome.Rejected, "circuit open");
}
CallResult result = operation();
if (result.Outcome == Outcome.Ok)
{
OnSuccess();
return result;
}
OnFailure();
return result;
}
private void OnSuccess()
{
consecutiveFailures = 0;
if (State == CircuitState.HalfOpen)
{
MoveTo(CircuitState.Closed, "trial call succeeded");
}
}
private void OnFailure()
{
consecutiveFailures++;
if (State == CircuitState.HalfOpen)
{
Trip("trial call failed");
return;
}
if (consecutiveFailures >= failureThreshold)
{
Trip($"{consecutiveFailures} consecutive failures");
}
}
private void Trip(string reason)
{
openedAt = clock.GetUtcNow();
MoveTo(CircuitState.Open, reason);
}
private void MoveTo(CircuitState next, string reason)
{
Console.WriteLine($" state {State} -> {next}: {reason}");
State = next;
}
}t=0s call 1: Failed (timeout)
t=0s call 2: Failed (timeout)
state Closed -> Open: 3 consecutive failures
t=0s call 3: Failed (timeout)
t=0s call 4: Rejected (circuit open)
t=10s call 5: Rejected (circuit open)
state Open -> HalfOpen: open duration elapsed
state HalfOpen -> Closed: trial call succeeded
t=30s call 6: Ok (USD/KRW 1380)
t=30s call 7: Ok (USD/KRW 1380)
api calls: 5Calls 4 and 5 are rejected without reaching the API, so seven calls generate only five API invocations. Each state-transition line is printed before the outcome of the call that caused it.
Extended example
A single BreakerPolicy collects the counted failures, opening threshold and open duration. Failures use ErrorKind; only Timeout and Unavailable count. InvalidRequest describes a bad request and is not counted. Exchange-rate lookup is read-only and does not change server state when repeated, so a retry wrapper allows at most two attempts outside the breaker. If a failed attempt opens the circuit, retries stop immediately without waiting for backoff. Rejected calls are not retried. The inventory API has its own breaker. In the output, failures is the number of consecutive failures.
using System;
using System.Collections.Generic;
var clock = new ManualClock();
// Use one breaker per dependency; an exchange-rate outage must not block inventory.
var policy = new BreakerPolicy(
FailureThreshold: 3,
OpenDuration: TimeSpan.FromSeconds(30),
CountsAsFailure: kind => kind is ErrorKind.Timeout or ErrorKind.Unavailable);
var ratesApi = new ScriptedApi(["timeout", "timeout", "invalid", "unavailable", "timeout", "ok"]);
var stockApi = new ScriptedApi(["ok"]);
var rates = new CircuitBreaker("rates", clock, policy);
var stock = new CircuitBreaker("stock", clock, policy);
GetRate(1);
GetRate(2);
GetRate(3);
GetRate(4);
Print("stock", 1, 1, stock.Execute(stockApi.Call), stock);
clock.Advance(TimeSpan.FromSeconds(30));
GetRate(5);
clock.Advance(TimeSpan.FromSeconds(14));
GetRate(6);
clock.Advance(TimeSpan.FromSeconds(16));
GetRate(7);
Console.WriteLine($"rates api calls: {ratesApi.Calls}, stock api calls: {stockApi.Calls}");
// Exchange-rate lookup is read-only; repeating it does not change server state, so retrying is allowed.
// Retry outside the breaker; stop if it is open or rejects a call.
void GetRate(int request)
{
const int maxAttempts = 2;
for (int attempt = 1; ; attempt++)
{
CallResult result = rates.Execute(ratesApi.Call);
Print("rates", request, attempt, result, rates);
bool retryable = result.Outcome == Outcome.Failed && policy.CountsAsFailure(result.Kind);
if (!retryable || attempt == maxAttempts)
{
return;
}
// If this failure opened the circuit, stop without backoff; another attempt would be rejected.
if (rates.State == CircuitState.Open)
{
Console.WriteLine(" retry stopped: circuit open");
return;
}
// Production waits for backoff; here advance the fake clock by one second.
clock.Advance(TimeSpan.FromSeconds(1));
}
}
void Print(string name, int request, int attempt, CallResult result, CircuitBreaker breaker) =>
Console.WriteLine($"t={clock.Seconds}s {name} #{request} try {attempt}: {result} [failures {breaker.ConsecutiveFailures}]");
enum CircuitState { Closed, Open, HalfOpen }
enum Outcome { Ok, Failed, Rejected }
enum ErrorKind { None, Timeout, Unavailable, InvalidRequest, CircuitOpen }
readonly record struct CallResult(Outcome Outcome, ErrorKind Kind)
{
public override string ToString() => Outcome == Outcome.Ok ? "Ok" : $"{Outcome} {Kind}";
}
// A policy collecting counted failures, the threshold and the open duration.
sealed record BreakerPolicy(int FailureThreshold, TimeSpan OpenDuration, Func<ErrorKind, bool> CountsAsFailure);
// Test clock: override only GetUtcNow; its value changes only through Advance.
sealed class ManualClock : TimeProvider
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private DateTimeOffset now = Start;
public override DateTimeOffset GetUtcNow() => now;
public void Advance(TimeSpan by) => now += by;
public int Seconds => (int)(now - Start).TotalSeconds;
}
// A fake API returning preset responses in order.
sealed class ScriptedApi(string[] script)
{
public int Calls { get; private set; }
public CallResult Call()
{
string next = script[Calls];
Calls++;
return next switch
{
"ok" => new CallResult(Outcome.Ok, ErrorKind.None),
"timeout" => new CallResult(Outcome.Failed, ErrorKind.Timeout),
"unavailable" => new CallResult(Outcome.Failed, ErrorKind.Unavailable),
"invalid" => new CallResult(Outcome.Failed, ErrorKind.InvalidRequest),
_ => throw new InvalidOperationException($"unknown script step: {next}"),
};
}
}
sealed class CircuitBreaker(string name, TimeProvider clock, BreakerPolicy policy)
{
private DateTimeOffset openedAt;
public CircuitState State { get; private set; } = CircuitState.Closed;
public int ConsecutiveFailures { get; private set; }
public CallResult Execute(Func<CallResult> operation)
{
if (State == CircuitState.Open && clock.GetUtcNow() - openedAt >= policy.OpenDuration)
{
MoveTo(CircuitState.HalfOpen, "open duration elapsed");
}
if (State == CircuitState.Open)
{
return new CallResult(Outcome.Rejected, ErrorKind.CircuitOpen);
}
CallResult result = operation();
if (result.Outcome == Outcome.Ok)
{
OnSuccess();
return result;
}
// A bad-request response shows the peer is alive. Count it as neither failure nor success.
if (!policy.CountsAsFailure(result.Kind))
{
return result;
}
OnFailure();
return result;
}
private void OnSuccess()
{
ConsecutiveFailures = 0;
if (State == CircuitState.HalfOpen)
{
MoveTo(CircuitState.Closed, "trial call succeeded");
}
}
private void OnFailure()
{
ConsecutiveFailures++;
if (State == CircuitState.HalfOpen)
{
// A failed trial reopens the circuit and starts a fresh openDuration from now.
Trip("trial call failed");
return;
}
if (ConsecutiveFailures >= policy.FailureThreshold)
{
Trip($"{ConsecutiveFailures} consecutive failures");
}
}
private void Trip(string reason)
{
openedAt = clock.GetUtcNow();
MoveTo(CircuitState.Open, reason);
}
private void MoveTo(CircuitState next, string reason)
{
Console.WriteLine($" {name}: {State} -> {next}: {reason}");
State = next;
}
}t=0s rates #1 try 1: Failed Timeout [failures 1]
t=1s rates #1 try 2: Failed Timeout [failures 2]
t=1s rates #2 try 1: Failed InvalidRequest [failures 2]
rates: Closed -> Open: 3 consecutive failures
t=1s rates #3 try 1: Failed Unavailable [failures 3]
retry stopped: circuit open
t=1s rates #4 try 1: Rejected CircuitOpen [failures 3]
t=1s stock #1 try 1: Ok [failures 0]
rates: Open -> HalfOpen: open duration elapsed
rates: HalfOpen -> Open: trial call failed
t=31s rates #5 try 1: Failed Timeout [failures 4]
retry stopped: circuit open
t=45s rates #6 try 1: Rejected CircuitOpen [failures 4]
rates: Open -> HalfOpen: open duration elapsed
rates: HalfOpen -> Closed: trial call succeeded
t=61s rates #7 try 1: Ok [failures 0]
rates api calls: 6, stock api calls: 1Both attempts in #1 count as failures, showing how retries increase the count. The InvalidRequest in #2 leaves that count at two. The first attempt in #3 becomes the third failure and opens the circuit at t=1s. The retry wrapper sees the open circuit and stops before its second attempt. #4 is rejected immediately afterward. At the same time, the inventory API remains callable because its own breaker is closed. The trial at t=31s fails, reopening the circuit and stopping retries again. The opening timestamp moves to 31 seconds, so t=45s is rejected. The successful trial at t=61s closes the circuit.
This breaker assumes calls arrive sequentially on one thread. Concurrent calls could admit several Half-Open trials and race while updating counts and state. A production implementation protects state with locks or atomic operations and defines the Half-Open call allowance, such as resilience4j's permittedNumberOfCallsInHalfOpenState. Because one success resets consecutive failures to zero, alternating successes and failures may never open this breaker. Libraries therefore use a failure ratio and minimum throughput within a sampling period, as Polly does, or sliding windows, as resilience4j does. The fake API also merely returns timeout itself; the example implements no timeout that actually interrupts slow work.
TypeScript example
This repeats the first C# scenario. A Clock interface returning milliseconds supplies time, and CallResult uses an outcome string-literal union to distinguish success, failure and rejection.
type CircuitState = "Closed" | "Open" | "HalfOpen";
type CallResult = { outcome: "Ok" | "Failed" | "Rejected"; detail: string };
interface Clock { now(): number; }
// Test clock: time moves only through advance.
class ManualClock implements Clock {
#ms = 0;
now(): number { return this.#ms; }
advance(ms: number): void { this.#ms += ms; }
get seconds(): number { return this.#ms / 1000; }
}
// A fake exchange-rate API: timeout for the first failFirst calls, then success.
class FakeRatesApi {
calls = 0;
constructor(private readonly failFirst: number) {}
getRate = (): CallResult => {
this.calls++;
return this.calls <= this.failFirst
? { outcome: "Failed", detail: "timeout" }
: { outcome: "Ok", detail: "USD/KRW 1380" };
};
}
class CircuitBreaker {
#state: CircuitState = "Closed";
#consecutiveFailures = 0;
#openedAt = 0;
constructor(
private readonly clock: Clock,
private readonly failureThreshold: number,
private readonly openDurationMs: number,
) {}
execute(operation: () => CallResult): CallResult {
// After openDuration, enter HalfOpen and send this call as the trial.
if (this.#state === "Open" && this.clock.now() - this.#openedAt >= this.openDurationMs) {
this.#moveTo("HalfOpen", "open duration elapsed");
}
// While open, reject immediately without invoking the dependency.
if (this.#state === "Open") {
return { outcome: "Rejected", detail: "circuit open" };
}
const result = operation();
if (result.outcome === "Ok") {
this.#onSuccess();
return result;
}
this.#onFailure();
return result;
}
#onSuccess(): void {
this.#consecutiveFailures = 0;
if (this.#state === "HalfOpen") this.#moveTo("Closed", "trial call succeeded");
}
#onFailure(): void {
this.#consecutiveFailures++;
if (this.#state === "HalfOpen") {
this.#trip("trial call failed");
return;
}
if (this.#consecutiveFailures >= this.failureThreshold) {
this.#trip(`${this.#consecutiveFailures} consecutive failures`);
}
}
#trip(reason: string): void {
this.#openedAt = this.clock.now();
this.#moveTo("Open", reason);
}
#moveTo(next: CircuitState, reason: string): void {
console.log(` state ${this.#state} -> ${next}: ${reason}`);
this.#state = next;
}
}
const clock = new ManualClock();
const api = new FakeRatesApi(3);
const breaker = new CircuitBreaker(clock, 3, 30_000);
function call(n: number): void {
const result = breaker.execute(api.getRate);
console.log(`t=${clock.seconds}s call ${n}: ${result.outcome} (${result.detail})`);
}
call(1);
call(2);
call(3);
call(4);
clock.advance(10_000);
call(5);
clock.advance(20_000);
call(6);
call(7);
console.log(`api calls: ${api.calls}`);t=0s call 1: Failed (timeout)
t=0s call 2: Failed (timeout)
state Closed -> Open: 3 consecutive failures
t=0s call 3: Failed (timeout)
t=0s call 4: Rejected (circuit open)
t=10s call 5: Rejected (circuit open)
state Open -> HalfOpen: open duration elapsed
state HalfOpen -> Closed: trial call succeeded
t=30s call 6: Ok (USD/KRW 1380)
t=30s call 7: Ok (USD/KRW 1380)
api calls: 5The output matches C#. These synchronous calls cannot overlap, but when a dependency call includes an await, several Half-Open calls can be in progress even on one thread.
See also
Each C# example is a standalone .NET 10 console program.
Compile the TypeScript examples to JavaScript targeting ES2022 or later, then run them in Node.js.
Footnotes
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). Solution. ↩
- Polly. Circuit breaker resilience strategy. ↩
- Michael T. Nygard. Release It!: Design and Deploy Production-Ready Software. Pragmatic Bookshelf (2007). Publisher's table-of-contents excerpt. Chapter 5, Stability Patterns: 5.2 Circuit Breaker. The excerpt is from the February 2009 P3.0 printing. ↩
- Michael Nygard. Release It! Second Edition. Pragmatic Bookshelf (2018). Publisher's page. Stability Patterns chapter in the table of contents. ↩
- Martin Fowler. CircuitBreaker (2014). ↩
- Netflix. Hystrix README. Hystrix Status. ↩
- resilience4j. CircuitBreaker. ↩
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). Context and problem. ↩
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). Problems and considerations. ↩
- Microsoft Azure Architecture Center. Circuit Breaker pattern (2025). When to use this pattern. ↩
- Microsoft Learn. Introduction to resilient app development (2025). ↩
- Microsoft Learn. Build resilient HTTP apps: Key development patterns (2026). Standard resilience handler defaults. ↩
- Microsoft Learn. TimeProvider Class. ↩