Quick answer: A circuit breaker is a state machine with three states: closed, open, and half-open. When the downstream dependency fails enough times, the breaker opens and the client gets a fast failure without hammering the broken service. After a cool-down it moves to half-open and lets a trial request through. To test client behavior honestly, mock the full sequence rather than a single static 503, because the interesting logic is how the client recovers, not just how it fails.

The three states and what each one means

Most breaker implementations, from Resilience4j to Polly and the Go-style hand-rolled versions, share the same model. The difference between them is mostly configuration and naming.

State What happens to requests Typical client response
Closed Requests flow normally; failures are counted Normal success or the underlying error
Open Requests fail immediately without calling the service Fast 503 or a rejected promise
Half-open A limited number of trial requests probe recovery Success closes the breaker; failure reopens it

Why a static stub is not enough

If your mock always returns 503 Service Unavailable, you can verify that the client shows an error, but you cannot test the two behaviors that matter most: that the client stops firing requests while the breaker is open, and that it recovers automatically once the dependency is healthy. A good mock advances time and state so the test can observe the transitions.

Modeling the breaker in a stateful mock

Here is a dependency-free Node mock that tracks failure counts and exposes the state transitions. The threshold opens the breaker, and a simulated wait moves it to half-open.

function createDownstream() { const FAILURE_THRESHOLD = 3; let failures = 0; let state = 'CLOSED'; let openedAt = 0; const COOLDOWN_MS = 5000; async function call(now) { if (state === 'OPEN') { if (now - openedAt >= COOLDOWN_MS) state = 'HALF_OPEN'; else return { status: 503, fast: true }; } const healthy = serviceHealthy; // flip this in your test if (healthy) { failures = 0; state = 'CLOSED'; return { status: 200 }; } failures += 1; if (state === 'HALF_OPEN' || failures >= FAILURE_THRESHOLD) { state = 'OPEN'; openedAt = now; } return { status: 503, fast: state === 'OPEN' }; } return { call, getState: () => state }; }

Writing the test around the transitions

Use fake timers so the cool-down does not slow the test, and assert on the state, not just the returned status.

test('opens after threshold, then recovers through half-open', async () => { jest.useFakeTimers(); const downstream = createDownstream(); serviceHealthy = false; for (let i = 0; i < 3; i++) await downstream.call(Date.now()); expect(downstream.getState()).toBe('OPEN'); // While open, the next call fails fast and does not touch the service. const fast = await downstream.call(Date.now()); expect(fast).toMatchObject({ status: 503, fast: true }); // After the cool-down, a trial request probes recovery. jest.advanceTimersByTime(5000); serviceHealthy = true; const trial = await downstream.call(Date.now()); expect(trial).toEqual({ status: 200 }); expect(downstream.getState()).toBe('CLOSED'); });

What to assert on the client side

  • Failures stop being sent to the downstream while the breaker is open, which protects a struggling service.
  • The open-state failure returns quickly rather than waiting for a connection timeout.
  • The half-open trial limits concurrency; a flood of requests should not all hit a service that may still be weak.
  • A successful trial closes the breaker and restores normal traffic, while a failed trial reopens it.

Open vs half-open failure semantics

One subtle point worth mocking explicitly: a 503 returned while the breaker is open is a local, instant decision, whereas a 503 during the closed state came from the actual service. The client can treat them differently, for example by skipping retry on an open breaker but retrying a genuine upstream failure. If your mock returns the same body for both, the client cannot distinguish them and you lose the ability to test the retry policy. Add a flag or a distinct error type so the branch is observable.

Connecting the mock to a contract

If the downstream is described in OpenAPI, the 503 response and any Retry-After header should be part of the operation's documented responses. A spec-driven mock can generate the status code from that contract while your test supplies the state machine, which keeps the response shape aligned with what the real service returns. This is more reliable than hand-writing a response body that drifts from the contract over time.

Frequently asked questions

What is the difference between a retry and a circuit breaker?

A retry repeats a single failed request after a short delay. A circuit breaker tracks failures across many requests and short-circuits traffic when the dependency is clearly down, then probes recovery. They work together: retries handle a transient blip, while the breaker prevents retries from overwhelming a service that is already failing.

Do I need to mock the breaker itself or the downstream service?

Mock the downstream responses and let the real breaker run against them, so you test the actual state machine and configuration. Mocking the breaker directly only verifies that your code calls it, not that the thresholds and cool-downs behave correctly.

What status code should an open breaker return?

HTTP services commonly use 503 Service Unavailable, often with a Retry-After header. Inside a single process, the breaker may reject with a named error instead. Match whatever the real boundary returns so the client handles it correctly.

How many failures should open the breaker?

There is no universal number. A low threshold reacts fast but can open on a brief blip; a high threshold is slower but more tolerant. Base it on the dependency's typical failure pattern and test both the threshold and the half-open recovery rather than guessing.

This is part of the The API Mocking Guide — start there for stateful patterns that model how real services fail and recover.