Testing That a Circuit Breaker Opens After the Right Number of Failures
9 min read · updated August 11, 2026
A circuit breaker is a small state machine with five parameters, and every one of them is testable without a network. The reason breakers ship broken is that people test the wrapper against a failing service and conclude it works, which exercises one transition out of five.
The state machine you are actually testing
Three states. Closed: calls pass through, failures are counted. Open: calls fail immediately without touching the transport, and a cooldown timer runs. Half-open: after the cooldown, a limited number of trial calls are admitted; success closes the breaker, failure reopens it.
The parameters are the failure threshold, what counts as a failure, the window or reset policy for the count, the cooldown duration, and the number of successes required to close from half-open. Each gets at least one test, and the tests are cheap because the breaker should not know what it is wrapping — it wraps a function that can throw. Inject a fake function and you never need a model, a provider or a socket.
If your breaker cannot be constructed without a real client, that is the first bug. The general case for the pattern and where it belongs in an LLM stack is covered in circuit breakers for AI calls; this page is about proving your particular one behaves.
Threshold, and what resets the count
Test the boundary from both sides. With a threshold of five: four failures leave the breaker closed and a fifth call still reaches the transport; the fifth failure opens it and a sixth call does not. Two assertions, and they are the ones that catch the off-by-one where a breaker opens on the fourth or the sixth.
import { beforeEach, expect, it, vi } from "vitest";
import { CircuitBreaker, CircuitOpenError } from "../src/breaker";
const boom = () => { throw new Error("upstream 503"); };
it("stays closed for threshold-1 failures and opens on the threshold", async () => {
const transport = vi.fn(boom);
const breaker = new CircuitBreaker({ failureThreshold: 5, cooldownMs: 30_000 });
for (let i = 0; i < 4; i++) await expect(breaker.call(transport)).rejects.toThrow("upstream 503");
expect(breaker.state).toBe("closed");
expect(transport).toHaveBeenCalledTimes(4);
await expect(breaker.call(transport)).rejects.toThrow("upstream 503");
expect(breaker.state).toBe("open");
expect(transport).toHaveBeenCalledTimes(5);
});Then test the reset policy, and be clear which one you have, because the two behave very differently under partial degradation. A consecutive counter resets to zero on any success, so a service failing half the time never trips a threshold of five. A rolling window counts failures within a period or a ratio of recent calls, and will trip. Write the test that distinguishes them: four failures, one success, four failures. Under a consecutive policy the breaker is still closed; under a window policy it is open. Whatever your answer, it should be in a test with a name that states the policy, because this is the behaviour people assume wrongly and then debug for an afternoon.
While open, nothing reaches the transport
The point of an open breaker is to stop load reaching a struggling dependency and to fail fast for the caller. Both halves need asserting, and the transport call count is the assertion that proves it — not the fact that an error was thrown, since an error would be thrown either way.
it("fails fast without calling the transport while open", async () => {
const transport = vi.fn(boom);
const breaker = openedBreaker({ transport, failureThreshold: 5 });
transport.mockClear();
await expect(breaker.call(transport)).rejects.toBeInstanceOf(CircuitOpenError);
expect(transport).not.toHaveBeenCalled();
});Assert the error type as well as the absence of the call. A breaker that rethrows the last upstream error while open is a breaker whose callers cannot distinguish “the provider is down” from “we are not currently trying” — and that distinction is what a fallback route keys on. If the caller cannot tell, it cannot decide to try the secondary provider instead of surfacing an error.
Half-open admits exactly one probe
Use fake timers for the cooldown, as with any timing test: Vitest’s vi.useFakeTimers() plus vi.advanceTimersByTimeAsync(ms) move virtual time without waiting. Assert the transition does not happen a millisecond early and does happen after the cooldown elapses.
Then assert the concurrency of the probe, which is the transition most implementations get wrong. If ten requests arrive the instant the cooldown expires, exactly one should be admitted to the transport and nine should still fail fast. A breaker that admits all ten sends a thundering herd at a service that has just come back, which is the failure mode the breaker existed to prevent — and it only appears under concurrency, so a sequential test will never show it.
it("admits exactly one probe when the cooldown expires", async () => {
const transport = vi.fn(async () => "ok");
const breaker = openedBreaker({ failureThreshold: 5, cooldownMs: 30_000 });
await vi.advanceTimersByTimeAsync(29_999);
await expect(breaker.call(transport)).rejects.toBeInstanceOf(CircuitOpenError);
await vi.advanceTimersByTimeAsync(1);
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => breaker.call(transport)),
);
expect(transport).toHaveBeenCalledTimes(1);
expect(results.filter((r) => r.status === "rejected")).toHaveLength(9);
});Cover the two exits from half-open as well. A failing probe returns the breaker to open, and the next cooldown should be longer if you back off — assert the second cooldown, because an unbacked-off breaker probes a dead service on a fixed interval forever. A succeeding probe moves toward closed, and if you require several consecutive successes, assert that a failure partway through the sequence sends it back to open rather than restarting the count.
Which errors count — the assertion most suites miss
A breaker that counts every exception will open on your own bugs. This is the highest-value test on the page and it is almost always absent.
A 400 for a malformed request is not the provider failing; it is your request being wrong, and every retry will produce it again. Opening the breaker on it takes down a working provider because one code path sent an invalid schema. A 401 is a credential problem. A 429 is a rate limit — the correct response is backoff and queueing, covered in testing that a rate limiter queues, not opening a breaker, and a breaker that opens on 429s will oscillate between open and closed for as long as you are near your limit. A content filter refusal is a normal outcome.
What should count: connection errors, timeouts, 5xx responses, and malformed or truncated responses that indicate the upstream is unhealthy. Write the test as a table over error types with the expected post-call state, so that adding a new classification is one row.
it.each([
["timeout", new TimeoutError(), "counts"],
["502 bad gateway", new HttpError(502), "counts"],
["connection reset", new ConnectionError(), "counts"],
["400 bad request", new HttpError(400), "ignored"],
["401 unauthorized", new HttpError(401), "ignored"],
["429 rate limited", new HttpError(429), "ignored"],
["content filtered", new ContentFilterError(), "ignored"],
])("%s is %s by the breaker", async (_n, error, expected) => {
const breaker = new CircuitBreaker({ failureThreshold: 1 });
await expect(breaker.call(() => { throw error; })).rejects.toThrow();
expect(breaker.state).toBe(expected === "counts" ? "open" : "closed");
});A threshold of one makes each row a single call, which keeps the table readable. The classification function it exercises should be the same one production uses — if the test defines its own idea of which errors count, it is asserting a duplicate of the logic rather than the logic.