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
ConsecutiveFailures—Simple mode: open after this many failures in a row
FailureRatio—Sampling mode: open when this fraction of calls fail (0 exclusive to 1 inclusive)
HalfOpenProbes1Completed outcomes per half-open cohort and maximum concurrent probes
SlowCallThreshold—Calls taking strictly longer than this duration count as slow; requires sampling mode and SlowCallRatio
SlowCallRatio—Open when this fraction of sampled calls is slow (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
BreakDurationGenerator—Awaited outcome, failure-statistics, and context-aware duration returning ValueTask<TimeSpan>; overrides BreakDuration for each trip
Monitor—A CircuitBreakerMonitor for observing + manual control
OnStateChanged—Awaited callback on every transition: e.From, e.To, e.LastException, e.Context
HandlesException—Local 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.

Recovery probes and slow calls​

Increase HalfOpenProbes to evaluate recovery using several calls. Each half-open cohort admits at most that many calls, including already completed probes. Simple mode reopens on its first handled failure. Sampling mode reopens when failures divided by HalfOpenProbes reach FailureRatio; this permits an occasional failed probe below the threshold. The circuit closes only after the whole cohort completes below every configured threshold. Unhandled exceptions and caller cancellation release a slot for a replacement without counting as completed observations.

Slow-call detection measures the downstream execution using the shield's TimeProvider. It observes latency without cancelling the call or changing its result:

var latencyAware = Shield.CircuitBreaker(options =>
{
options.FailureRatio = 0.5;
options.MinimumThroughput = 20;
options.SamplingWindow = TimeSpan.FromSeconds(30);
options.HalfOpenProbes = 4;
options.SlowCallThreshold = TimeSpan.FromMilliseconds(250);
options.SlowCallRatio = 0.75;
});

Set both slow-call options together. The failure ratio and slow-call ratio are independent trip conditions over the same window and minimum throughput. A call that both fails and is slow counts once in the total, once in each applicable numerator. Successful calls and handled failures are measured; unhandled exceptions and caller cancellation remain excluded. In half-open state, slow probes divided by HalfOpenProbes are checked against SlowCallRatio, independently of MinimumThroughput. An opening caused by slow successful calls can have no LastException.

Kevlar.Testing exposes ProbesInFlight and SlowCallCount through GetStateSnapshot() and CircuitBreakerStateSnapshot. The slow count covers the current closed-state sampling window; half-open observations are evaluated separately. Closing or resetting the circuit clears the window. GetDescriptor() exposes the three configured settings, and CircuitBreakerDefinition accepts the same fields when binding configuration.

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 a handled failure or slow-call observation crosses a 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.

Failure statistics continue to describe handled failures when a slow-call threshold triggers the generator. While a half-open duration generator is pending, new probes are rejected and other completions cannot close the circuit. If generation fails, completed observations are discarded; still-running probes retain their slots and replacements may fill the remaining cohort.

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 and configured slow calls are measured.
  • Open — executions are rejected immediately with CircuitOpenException (carrying RetryAfter: the time until a probe is allowed).
  • HalfOpen — after the break duration, up to HalfOpenProbes calls are admitted (default one). A healthy completed cohort closes the circuit and resets metrics; reaching a configured failure or slow-call threshold reopens it. Calls beyond the cohort limit are rejected (RetryAfter == null).
  • Isolated — manually forced open via the monitor; rejected until Reset().
Fast-fail without throwing

While the circuit is open, every call is a rejection. If callers handle that rejection routinely, execute with ExecuteOutcomeAsync. It returns the CircuitOpenException, including RetryAfter, as a value instead of throwing it:

private static readonly Shield _userBreaker = Shield.CircuitBreaker(
consecutiveFailures: 5,
breakDuration: TimeSpan.FromSeconds(30));

static async ValueTask<User?> LoadUnlessOpenAsync(
CancellationToken cancellationToken)
{
Outcome<User> outcome = await _userBreaker.ExecuteOutcomeAsync(
ct => LoadAsync(ct),
cancellationToken);
if (outcome.Exception is CircuitOpenException open)
{
Console.WriteLine($"Circuit open; retry after {open.RetryAfter}");
return null;
}

return outcome.GetResultOrRethrow();
}

In a local BenchmarkDotNet run, an isolated-circuit rejection cost 3.21 μs and 1,312 B when thrown and caught, and 86 ns and 144 B through ExecuteOutcomeAsync. See Hot rejection paths.

After the break duration elapses, CircuitBreakerMonitor.State reports HalfOpen immediately, even before another execution arrives. Admission remains lazy: the actual Open → HalfOpen 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.