Explain the circuit breaker pattern — what are its states and when does each transition happen?
Named after the fuse in your wall for a reason: a circuit breaker stops a caller from hammering a failing dependency, failing fast instead of piling up threads that never come back.
LEARN BY DOINGPut your intuition to work3 steps
Explain the circuit breaker pattern — what are its states and when does each transition happen?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The Fuse Box for Service Calls
Its name comes directly from the household electrical fuse, and the analogy is almost exact: a circuit breaker sits between a caller and a dependency, and when that dependency starts failing, the breaker "trips" and stops sending it current — requests — instead of letting every caller keep hammering a service that's already struggling. Without one, a slow or failing downstream dependency causes callers to pile up threads, connections, or memory waiting on it; once the caller exhausts its own resource pool, it becomes slow or unavailable too, and the failure cascades up the entire call graph. The circuit breaker's job isn't to fix the dependency — it's to fail fast, protect the caller's own resources, and give the dependency room to recover.
#The three states
Closed is normal operation: every request is allowed through to the real dependency. The breaker isn't idle, though — it's counting outcomes over a rolling window (either the last N calls or the last T seconds) and computing a live failure rate. The moment that rate crosses a configured threshold (say, 50% of the last 20 calls failed, or 10 failures in 10 seconds), the breaker trips to Open.
Open stops sending real traffic entirely: every call fails immediately — typically by throwing a "circuit open" error or invoking a fallback — without ever touching the network. This is the fail-fast behavior that protects the caller: instead of a thread blocking for a 30-second timeout against a dependency that's already down, it fails in microseconds. The breaker stays Open for a configured reset timeout (sometimes called a sleep window), then transitions itself to Half-Open — nobody has to flip it back manually.
Half-Open is a probe: the breaker lets a small number of trial requests through to the real dependency (often just one) while still blocking everything else. If the trial(s) succeed, the breaker assumes the dependency has recovered, resets its counters, and returns to Closed. If a trial fails, it assumes the dependency is still broken, goes straight back to Open, and restarts the reset timeout — it does not go back through a slow ramp, and it does not let more traffic through just because some time has passed.
#What it's not a substitute for
A circuit breaker without a per-call timeout on the underlying request is close to useless: if a call to a hung dependency never returns, the breaker can't count it as a failure until the timeout fires, so it can't trip in time to help — the timeout is what turns "hanging" into "failing," which is the only thing a breaker can react to. Circuit breakers also pair naturally with bulkheading (giving each dependency its own thread pool or connection pool so one dependency's resource exhaustion can't starve calls to a healthy one) — a breaker stops you from calling a failing dependency, but only a bulkhead stops that dependency from having already eaten all your threads before the breaker had enough data to trip.
A senior-level nuance worth naming explicitly: not every error should count against the breaker. A 500 from the dependency, a connection refused, or a timeout are signs the dependency itself is unhealthy and should count. A 400 caused by our own malformed request is a bug on our side — retrying it won't help and won't be fixed by giving the dependency a break, so counting it toward the trip threshold would open the circuit for the wrong reason and needlessly deny service to requests that would have succeeded.
#A real implementation
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';interface CircuitBreakerOptions {failureThreshold: number; // failures needed within the window to tripwindowSizeMs: number; // rolling window for counting failuresresetTimeoutMs: number; // how long to stay Open before probingisFailure?: (err: unknown) => boolean; // classify which errors count}export class CircuitBreaker {private state: CircuitState = 'CLOSED';private failureTimestamps: number[] = [];private openedAt = 0;constructor(private options: CircuitBreakerOptions) {}async execute<T>(fn: () => Promise<T>, fallback?: () => T): Promise<T> {if (this.state === 'OPEN') {if (Date.now() - this.openedAt >= this.options.resetTimeoutMs) {this.state = 'HALF_OPEN';} else {return this.handleRejection(fallback);}}try {const result = await fn();this.onSuccess();return result;} catch (err) {const counts = this.options.isFailure ? this.options.isFailure(err) : true;if (counts) {this.onFailure();}if (fallback) {return fallback();}throw err;}}private onSuccess(): void {if (this.state === 'HALF_OPEN') {this.state = 'CLOSED';this.failureTimestamps = [];}// In CLOSED state a success doesn't need special handling --// the rolling window naturally ages old failures out below.}private onFailure(): void {const now = Date.now();if (this.state === 'HALF_OPEN') {// The trial request failed -- the dependency is still broken.this.tripOpen(now);return;}this.failureTimestamps.push(now);this.failureTimestamps = this.failureTimestamps.filter((t) => now - t <= this.options.windowSizeMs);if (this.failureTimestamps.length >= this.options.failureThreshold) {this.tripOpen(now);}}private tripOpen(now: number): void {this.state = 'OPEN';this.openedAt = now;this.failureTimestamps = [];}private handleRejection<T>(fallback?: () => T): T {if (fallback) {return fallback();}throw new Error('Circuit is OPEN -- call rejected without hitting the dependency');}getState(): CircuitState {return this.state;}}// Usage:const inventoryBreaker = new CircuitBreaker({failureThreshold: 5,windowSizeMs: 10_000,resetTimeoutMs: 30_000,isFailure: (err) => !(err instanceof ValidationError), // our own 400s don't count});async function checkStock(sku: string) {return inventoryBreaker.execute(() => inventoryClient.getStock(sku),() => ({ sku, quantity: 0, stale: true }) // degrade gracefully instead of throwing);}
The shape above mirrors what production libraries like resilience4j (Java/Kotlin) or Polly (.NET) provide out of the box, plus what Hystrix popularized before Netflix deprecated it in favor of resilience4j: a rolling failure window, a cooldown, a probe state, and a pluggable fallback. The main thing hand-rolled versions get wrong is forgetting the isFailure classification step and tripping the breaker on errors the dependency had no part in causing.
| State | Sends real traffic? | Exit condition |
|---|---|---|
| Closed | Yes | Failure rate crosses threshold within the window -> Open |
| Open | No — fails fast / fallback | Reset timeout elapses -> Half-Open |
| Half-Open | Only a limited trial | Trial succeeds -> Closed; trial fails -> Open (timeout restarts) |
:::analogy It's named after the thing in your wall for a reason A household breaker doesn't fix a short circuit — it disconnects the circuit so the short doesn't burn the house down, and someone can flip it back once the fault is cleared. A software circuit breaker disconnects a failing dependency so it doesn't burn down the caller, and it flips itself back to test whether the fault has cleared. :::
:::warning A circuit breaker without a timeout on the call can't trip in time to matter If the wrapped call can hang indefinitely, the breaker never gets enough failed calls fast enough to open — the caller's threads are already exhausted by the time the failure count catches up. Always set an aggressive timeout on the underlying call itself; the breaker reacts to failures, it doesn't create them. :::
:::key Three states, three jobs Closed watches and lets traffic through. Open protects the caller by failing fast without touching the dependency. Half-Open probes, with the smallest possible blast radius, whether it's safe to go back to Closed. :::
🧠 Check your understanding
0/3 · 0/3 answered1. A circuit breaker has been Open for exactly its configured reset timeout. What happens on the next request?
2. Why is a per-call timeout considered a prerequisite for a circuit breaker to work correctly, not just a nice-to-have?
3. Why should a 400 Bad Request caused by malformed input from the caller typically NOT count toward tripping the breaker?