How to Build a Circuit Breaker with Half Open
Build a circuit breaker with a half-open state so a failing dependency is given a controlled probe, and recovery is tested before traffic resumes.
On this page
Introduction
The hardest part of protect from failing deps is not writing the code — it is deciding what the code is allowed to reject. A circuit breaker setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in circuit breaker, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
A circuit breaker guard for protect from failing deps should be proportional to the risk. A ten-field form does not need a hundred-line schema; it needs the five checks that catch the five mistakes the users actually make. Ship the five, measure the rejections, and add the sixth only when the traffic proves it exists. Speculative generality is the bug you write on purpose, and circuit breaker makes it easy to write, which is why discipline matters.
The sections below walk through the concrete decisions: where to put the circuit breaker check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Track failure count
Track failure count is the first decision because it determines what the rest of the circuit breaker pipeline can assume. A wrong choice here turns into protect from failing deps under load, and the invariant you thought circuit breaker guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Track failure count is the one most teams skip: drive circuit breaker to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
class CB {
state = 'closed'; fails = 0;
async call(fn) {
if (this.state === 'open') throw new Error('open');
try { const r = await fn(); this.onSuccess(); return r; }
catch (e) { this.onFail(); throw e; }
}
}
The typescript above is the shape to ship for protect from failing deps. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Open on threshold
The mechanism behind Open on threshold is straightforward in circuit breaker, but the consequences ripple. A wrong choice here turns into protect from failing deps under load, and the invariant you thought circuit breaker guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Open on threshold is the one most teams skip: drive circuit breaker to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Half-open on timeout
Half-open on timeout is where most circuit breaker implementations leak performance. A wrong choice here turns into protect from failing deps under load, and the invariant you thought circuit breaker guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Half-open on timeout is the one most teams skip: drive circuit breaker to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('opens after threshold', () => {
cb.fails = 5;
expect(() => cb.call(fn)).toThrow('open');
});
Pin the behavior with this test. A circuit breaker setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Allow one probe
The hard part of Allow one probe is not the happy path — it is what happens when circuit breaker fails halfway. A wrong choice here turns into protect from failing deps under load, and the invariant you thought circuit breaker guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Allow one probe is the one most teams skip: drive circuit breaker to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Close on success
Close on success should be observable without a debugger. A wrong choice here turns into protect from failing deps under load, and the invariant you thought circuit breaker guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Close on success is the one most teams skip: drive circuit breaker to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test state transitions
Test state transitions is a trade, not a winner. A wrong choice here turns into protect from failing deps under load, and the invariant you thought circuit breaker guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test state transitions is the one most teams skip: drive circuit breaker to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in protect from failing deps all reduce to one mistake: treating circuit breaker as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the circuit breaker boundary so protect from failing deps enters unvalidated.
- Assuming circuit breaker is fast without measuring the tail latency under load.
- Letting protect from failing deps fail silently instead of failing loudly with a metric.
- Testing only the happy path so the circuit breaker failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: circuit breaker fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives circuit breaker to the failing condition and assert the system degrades gracefully. Then protect from failing deps is a property you maintain, not a feature you hope for.
Sources: Circuit Breaker, Resilience4j
FAQ
- Why half-open?
- It tests recovery with one request. If it passes, the circuit closes; if not, it stays open.
- Failure threshold?
- Tune to your error budget. 5 failures in 10s is a common start.
Report a problem with this articleOpens a pre-filled issue on GitHub.