Skip to main content

Circuit Breaker

Stop hammering a dependency that's already failing. The breaker measures failures and, once a threshold is crossed, rejects executions outright for a break duration — giving the dependency room to recover.

See the exceptions reference for CircuitOpenException and its recovery metadata.

Two modes

// Simple: open after N consecutive failures
var consecutive = Shield.CircuitBreaker(
consecutiveFailures: 5,
breakDuration: TimeSpan.FromSeconds(30));

// Sampling: open when ≥50% of calls fail within a rolling window
var sampling = Shield.CircuitBreaker(o =>
{
o.FailureRatio = 0.5;
o.MinimumThroughput = 20;
o.SamplingWindow = TimeSpan.FromSeconds(30);
o.BreakDuration = TimeSpan.FromSeconds(15);
});

Configure either ConsecutiveFailures or FailureRatio — not both. When neither is set, the breaker trips after 5 consecutive failures.

Options

API reference: CircuitBreakerOptions and CircuitBreakerOptions<T>.

OptionDefaultWhat it does
ConsecutiveFailuresSimple mode: open after this many failures in a row
FailureRatioSampling mode: open when this fraction of calls fail (0 exclusive to 1 inclusive)
MinimumThroughput10Sampling mode: don't judge until at least this many calls landed in the window
SamplingWindow30sRolling window over which the ratio is measured (tracked in 10 buckets)
BreakDuration15sHow long the circuit stays open before allowing a probe
BreakDurationGeneratorAwaited outcome, failure-statistics, and context-aware duration returning ValueTask<TimeSpan>; overrides BreakDuration for each trip
MonitorA CircuitBreakerMonitor for observing + manual control
OnStateChangedAwaited callback on every transition: e.From, e.To, e.LastException, e.Context
HandlesExceptionLocal exception predicate; replaces the ambient clause for this breaker
HandlesResult (CircuitBreakerOptions<T>)Local result predicate on Shield<T>; replaces the ambient clause together with HandlesException

Invalid option values throw KevlarConfigurationException and identify the options type, property, and offending value. This also applies when BreakDurationGenerator returns a non-positive duration.

Dynamic break duration

Use BreakDurationGenerator when the dependency supplies a recovery hint or different failures need different cooling periods:

var breaker = Shield.CircuitBreaker(o =>
{
o.ConsecutiveFailures = 3;
o.BreakDurationGenerator = async trip =>
{
await Task.Yield(); // for example, fetch a dependency recovery hint
trip.Context.CancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Opening at {trip.FailureRate:P0} failures");
return trip.Exception is TimeoutExceededException
? TimeSpan.FromMinutes(1)
: TimeSpan.FromSeconds(30);
};
});

The generator runs after the handled outcome crosses the threshold and before the circuit changes to Open. It receives FailureRate, FailureCount, and ConsecutiveFailures at that moment and is awaited outside the circuit lock. Its duration must be positive; its exception or cancellation propagates unchanged and leaves the circuit available for a later trip. Untyped shields expose Exception and boxed Result; CircuitBreakerOptions<T> instead receives CircuitBreakerBreakDurationEvent<T> with a directly stored Outcome<T>. Context is pooled and must not be retained after the callback completes. A dynamic breaker describes itself as break dynamic without running the generator.

When duration computation is synchronous, return a completed value (trip => new(duration)); that form costs nothing extra and works with synchronous Execute. A generator or OnStateChanged hook that yields is awaited by ExecuteAsync, but reached through synchronous Execute it throws NotSupportedException at that call. See synchronous execution compatibility.

The state machine

Closed ──(threshold crossed)──► Open ──(BreakDuration elapses)──► HalfOpen
▲ ▲ │
│ │ probe fails │ probe succeeds
└───────────────────────────────┴──────────────────────────────────┘
  • Closed — executions flow normally; failures are measured.
  • Open — executions are rejected immediately with CircuitOpenException (carrying RetryAfter: the time until a probe is allowed).
  • HalfOpen — after the break duration, exactly one probe execution is allowed through. Success closes the circuit and resets metrics; failure re-opens it for another BreakDuration. Concurrent callers during the probe are rejected (RetryAfter == null).
  • Isolated — manually forced open via the monitor; rejected until Reset().

After the break duration elapses, CircuitBreakerMonitor.State reports HalfOpen immediately, even before another execution arrives. Admission remains lazy: the actual OpenHalfOpen transition and its state-change callbacks occur when the next execution claims the probe slot. Executions admitted before the current open/half-open generation cannot later close or re-open the circuit. The exception that opened the circuit is retained for open rejections, then released when the circuit closes or is reset.

Unhandled exceptions don't move the circuit

An exception outside the breaker's handling clause says nothing about downstream health — it counts as neither success nor failure. Caller cancellation is always unhandled while the caller's token is signaled, even when the clause matches OperationCanceledException. An unhandled half-open probe releases the probe slot without closing the circuit.

Observing and controlling: CircuitBreakerMonitor

var monitor = new CircuitBreakerMonitor();
var shield = Shield.CircuitBreaker(o =>
{
o.FailureRatio = 0.5;
o.Monitor = monitor;
o.OnStateChanged = async c =>
{
logger.LogWarning("Circuit {From} -> {To}", c.From, c.To);
await Task.Yield(); // for example, publish the transition to an audit sink
logger.LogInformation("Recorded circuit transition to {State}", c.To);
};
});

_ = monitor.State; // Closed / Open / HalfOpen / Isolated
monitor.StateChanged += e => metrics.Record(e.To);
monitor.Isolate(); // force open (maintenance switch)
monitor.Reset(); // close and clear metrics
await monitor.IsolateAsync(); // async equivalents await transition callbacks
await monitor.ResetAsync();

A monitor can bind to one or more breakers: assign it to each CircuitBreakerOptions.Monitor you want to control, and keep your reference. Isolate and Reset fan out to every bound breaker. State reports the worst current state, ordered Isolated, Open, HalfOpen, then Closed. Using a monitor before binding still throws.

Each breaker raises its own events. Transitions are delivered serially per breaker in state-change order: awaited OnStateChanged, then monitor.StateChanged. Different breakers bound to the same monitor may publish concurrently. The monitor intentionally retains an Action<CircuitBreakerStateChangedEvent> event so both observers share one event shape and delivery model. Execution-driven transitions carry the triggering pooled context. Manual Isolate/Reset transitions carry a detached context with StrategyIndex == -1, no shield name, and an empty property bag. Callbacks run outside the circuit lock, so they can read State or call the synchronous or asynchronous monitor controls; a reentrant transition is queued behind the transition currently being delivered. Use ResetAsync() and IsolateAsync() when OnStateChanged may yield so the calling thread is not blocked; these methods await every bound breaker in binding order. Transition publication is serialized per breaker. A publisher arriving while another thread drains observers waits for that drain; an execution can therefore occupy a thread-pool thread until the earlier handlers return. If an observer throws, later observers still run and the circuit keeps its new, usable state. Observer failures are reported through KevlarDiagnostics.OnCallbackError under the shared callback-failure contract and never replace an execution outcome or block a transition.

Share the circuit deliberately

A breaker only protects a dependency if every call site hitting that dependency shares it. State lives with the shield instance:

var breaker = Shield.CircuitBreaker(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30)); // ONE circuit
var reads = Shield.Retry(3).Wrap(breaker);
var writes = Shield.Timeout(TimeSpan.FromSeconds(5)).Wrap(breaker);
// failures through either trip both

See Composition.

For independent breaker state per tenant or endpoint, create the breaker inside a partitioned shield.

What counts as a failure

Whatever the current handling clause says — including handled results on typed shields, so an HTTP breaker can trip on 5xx responses without a single exception being thrown.

For one breaker with different rules, set HandlesException or HandlesResult in its options. This local override replaces, rather than extends, the ambient clause.