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.

Author
José Morales
Published
Reading
9 min

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

Circuit breaker states
state
success, or failure belowthe thresholdconsecutive failures reachthe thresholdcall rejected withoutrunningopenTimeout elapsedthe probe succeededthe probe failedclosedopenhalf-open
A timer does not trigger the transition from open to half-open. The first call that arrives after the timeout triggers it.
  • 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

src/resilience/circuit-breaker.tsts
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;
    }
  }
}

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.

src/resilience/call-with-resilience.tsts
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;
}
step 1/5
  1. The policy

    The per-attempt timeout and the retry predicate are explicit. Without isRetryable, a 400 would be retried three times.

  2. Each attempt goes through the breaker

    AbortSignal.timeout creates a new signal per attempt. The operation passes it to fetch, so the attempt is cancelled instead of hanging with nobody waiting for it.

  3. 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.

  4. Wait before the next attempt

    The wait comes after the catch, and only when another attempt is ahead.

  5. 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.

Written by

José Morales

Senior Backend Engineer

Backend and BFFs in production. I write about architecture, distributed systems, and the craft of writing code others can maintain.