The circuit breaker is a state machine
A circuit breaker modeled as an explicit state machine, with thresholds, timeouts, the half-open probe, and how to combine it with retries and jittered backoff. Implemented in TypeScript and Go.
The payment provider takes 20 seconds to respond. Every request to your BFF waits those 20 seconds, holds a socket and memory, and fails anyway. A few minutes later, you are the one who is down. A circuit breaker cuts that chain. It stops calling the failing dependency, responds fast, and now and then checks whether the dependency has recovered. Almost every homegrown implementation I have seen writes it with loose boolean flags. It is simpler and more correct to write it as what it is, a state machine with three states.
The three states
- Closed. Calls go through. The breaker counts consecutive failures, and a success resets the counter.
- Open. Calls fail at once with
CircuitOpenError, without touching the dependency. The breaker records when it opened. - Half-open. Once the timeout has passed, it lets a single probe call through. If the probe succeeds, it closes. If it fails, it opens again and the timeout restarts. While the probe is in flight, it rejects every other call.
The state is a discriminated union. Each variant carries only the data that makes sense in it. An open breaker has no failure counter, and a closed one has no openedAt. The compiler does not let you read a field that does not belong.
The implementation
export class CircuitOpenError extends Error {
constructor() {
super('Circuit is open');
this.name = 'CircuitOpenError';
}
}
export type BreakerOptions = {
failureThreshold: number;
openTimeoutMs: number;
now?: () => number;
};
type State =
| { kind: 'closed'; failures: number }
| { kind: 'open'; openedAt: number }
| { kind: 'half-open' };
export class CircuitBreaker {
private state: State = { kind: 'closed', failures: 0 };
private readonly now: () => number;
constructor(private readonly options: BreakerOptions) {
this.now = options.now ?? Date.now;
}
async call<T>(operation: () => Promise<T>): Promise<T> {
this.beforeCall();
try {
const result = await operation();
this.afterCall(true);
return result;
} catch (error) {
this.afterCall(false);
throw error;
}
}
private beforeCall(): void {
const state = this.state;
if (state.kind === 'half-open') throw new CircuitOpenError();
if (state.kind === 'open') {
if (this.now() - state.openedAt < this.options.openTimeoutMs) throw new CircuitOpenError();
this.state = { kind: 'half-open' };
}
}
private afterCall(succeeded: boolean): void {
const state = this.state;
switch (state.kind) {
case 'closed': {
const failures = succeeded ? 0 : state.failures + 1;
this.state =
failures >= this.options.failureThreshold
? { kind: 'open', openedAt: this.now() }
: { kind: 'closed', failures };
return;
}
case 'half-open':
this.state = succeeded
? { kind: 'closed', failures: 0 }
: { kind: 'open', openedAt: this.now() };
return;
case 'open':
// A call that started before the breaker opened. Its result no longer counts.
return;
}
}
}package breaker
import (
"context"
"errors"
"sync"
"time"
)
var ErrOpen = errors.New("circuit open")
type state int
const (
closed state = iota
open
halfOpen
)
type Breaker struct {
mu sync.Mutex
state state
failures int
openedAt time.Time
threshold int
openTimeout time.Duration
now func() time.Time
}
func New(threshold int, openTimeout time.Duration) *Breaker {
return &Breaker{threshold: threshold, openTimeout: openTimeout, now: time.Now}
}
func (b *Breaker) Call(ctx context.Context, op func(context.Context) error) error {
if err := b.beforeCall(); err != nil {
return err
}
err := op(ctx)
b.afterCall(err)
return err
}
func (b *Breaker) beforeCall() error {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case open:
if b.now().Sub(b.openedAt) < b.openTimeout {
return ErrOpen
}
b.state = halfOpen
case halfOpen:
return ErrOpen
}
return nil
}
func (b *Breaker) afterCall(err error) {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case closed:
if err == nil {
b.failures = 0
return
}
b.failures++
if b.failures >= b.threshold {
b.trip()
}
case halfOpen:
if err == nil {
b.state = closed
b.failures = 0
return
}
b.trip()
}
}
func (b *Breaker) trip() {
b.state = open
b.openedAt = b.now()
b.failures = 0
}Both versions have the same shape. One function decides whether the call may go through, and another applies the transition based on the result. In Go the state is shared between goroutines, so a mutex guards both functions. The operation runs outside the lock, because otherwise the breaker would serialize every call. TypeScript needs no lock because beforeCall and afterCall are synchronous and the event loop does not interrupt them.
The clock is injected. With a controlled now, a test walks through every transition without a single sleep.
Thresholds and timeouts
What counts as a failure
Not every error means a sick dependency. A 404 or a 422 says that the dependency works and the request was wrong. If you count them as failures, one client that sends garbage opens the circuit for everyone. Count timeouts, connection errors, and 5xx. In production, pass an isFailure predicate in the options. I left it out so the code fits on one screen.
Consecutive failures or error rate
Counting consecutive failures is simple and works well with low or medium traffic. With heavy traffic, a single success among 50 failures resets the counter and the breaker never opens. There you want a sliding window with an error rate and a minimum number of calls, which is what Resilience4j does. The state machine does not change. Only the condition for the closed-to-open transition changes.
The timeout of each call
The breaker does not replace a timeout. A call that hangs forever never fails, so it never adds to the counter. Each attempt needs its own timeout, and that timeout has to be shorter than your caller’s deadline. If your client waits 5 seconds, a 10-second attempt makes no sense.
Retries with backoff, around the breaker
The breaker and retries solve different problems. Retries cover transient failures, such as a lost packet or a restarting pod. The breaker covers persistent failures. Combined, each attempt goes through the breaker, and an open breaker stops the retries dead.
import { setTimeout as sleep } from 'node:timers/promises';
import { CircuitOpenError, type CircuitBreaker } from './circuit-breaker.js';
export type RetryPolicy = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
attemptTimeoutMs: number;
isRetryable: (error: unknown) => boolean;
};
export async function callWithResilience<T>(
breaker: CircuitBreaker,
operation: (signal: AbortSignal) => Promise<T>,
policy: RetryPolicy,
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await breaker.call(() => operation(AbortSignal.timeout(policy.attemptTimeoutMs)));
} catch (error) {
const exhausted = attempt >= policy.maxAttempts;
if (exhausted || error instanceof CircuitOpenError || !policy.isRetryable(error)) throw error;
}
await sleep(fullJitter(attempt, policy));
}
}
function fullJitter(attempt: number, policy: RetryPolicy): number {
const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
return Math.random() * ceiling;
}- The policy
The per-attempt timeout and the retry predicate are explicit. Without
isRetryable, a400would be retried three times. - Each attempt goes through the breaker
AbortSignal.timeoutcreates a new signal per attempt. The operation passes it tofetch, so the attempt is cancelled instead of hanging with nobody waiting for it. - When to stop trying
It stops when the attempts run out, when the breaker is open, or when the error is not transient. Waiting and retrying against an open circuit only adds latency.
- Wait before the next attempt
The wait comes after the
catch, and only when another attempt is ahead. - Full jitter
The wait is a random value between 0 and
min(maxDelayMs, baseDelayMs * 2^(n-1)). Without jitter, every client that failed together retries together and takes the dependency down again.
Retries multiply load. If the BFF retries 3 times, the orders service retries 3 times, and the payments adapter retries another 3, one provider failure turns into 27 calls. Retry in a single layer, usually the one closest to the dependency, and let the others fail fast.
deep diveRetries inside or outside the breaker, and stale results
With the retry outside, as in the code above, every failed attempt adds to the breaker’s counter. With a threshold of 5, two requests that use up their three attempts are enough to open it. If you put the retry inside, the whole sequence counts as a single failure and the breaker takes longer to open. I prefer outside. The breaker sees the dependency’s real failure rate and stops the retries as soon as it opens.
The second detail is subtler. A call that started in closed can finish when the breaker is already half-open. If it succeeds, afterCall sees half-open and closes the circuit, even though the real probe has not finished. For a single-probe breaker the effect is minor, because that call did reach the dependency and got a good response. If you need precision, keep a generation number that increases on every transition. Each call captures the generation when it starts, and afterCall ignores results from earlier generations.
What to return when the circuit is open
CircuitOpenError is a signal for the caller, not an error for the user. The controller translates it case by case. A 503 with Retry-After set to the time left until the probe is honest. A cached value can be better, for example the last known list of payment methods. A charge has no possible degradation, so 503 is the right response and the client retries later with its idempotency key.
Summary
- Model the breaker as a discriminated union of three states. Each state carries only its own data.
- Count as failures only what signals a sick dependency, such as timeouts, connection errors, and
5xx. - Use consecutive failures with low traffic and a rate over a window with high traffic.
- Keep the per-attempt timeout below the caller’s deadline, and retry outside the breaker with full jitter.
- Retry in a single layer and use one breaker per dependency, with metrics for every transition.