← Devlog

The circuit breaker that could never recover

A circuit breaker that opens on failure but never transitions back to closed — because the recovery call was never wired in. A safety mechanism that becomes a permanent outage. Plus the circular health metrics that hid it.

The circuit breaker that could never recover

A circuit breaker is supposed to be a self-healing safety mechanism. When a tool starts failing, the breaker opens and stops calls from going through. After a recovery timeout, it transitions to half-open, lets a test call through, and either closes (the tool recovered) or re-opens (it didn’t). That’s the pattern. That’s what every textbook says.

TOMA had a circuit breaker that opened on failure and never closed again. Ever. Once a tool tripped its breaker, that tool was dead until the server restarted. The safety mechanism became a permanent outage.

The bug

The circuit breaker has three states: Closed (normal), Open (blocking), HalfOpen (allowing one test call). The recovery path is straightforward: after the recovery timeout expires, the breaker should transition from Open to HalfOpen so the next call can probe whether the tool has recovered.

The orchestrator checks whether a tool can be invoked before every call. That check asks the breaker: are you open? If yes, the call is blocked. But the orchestrator never called the recovery function — the one that checks whether the timeout has expired and transitions Open to HalfOpen. So the breaker sat in Open forever. The timeout expired. Nobody asked.

The fix is one line: call the recovery function before the can-invoke check. But the bug is structural, not a missing line. The recovery transition was a method on the registry that nothing in the call path invoked. It existed, it was correct, and it was dead code — a safety mechanism with no trigger.

How it hid

The circuit breaker bug hid behind a second bug in the health metrics. The registry tracks success rate and error rate per tool, and those rates feed the circuit breaker’s failure threshold. When a tool’s error rate crosses the threshold, the breaker opens.

The rate calculation was circular. Instead of computing the rate from actual success and failure counts, it derived the new rate from the old rate — a running update that compounded rounding errors and never reflected the true ratio. A tool that failed 10 times in a row might show an error rate of 0.6, not 1.0, because the calculation was averaging averages, not counting counts.

So the breaker that never recovered was fed metrics that were never accurate. Two bugs, one system: the safety mechanism was both blind (bad metrics) and stuck (no recovery). Either one alone would have been a problem. Together they meant the circuit breaker was a theater piece — it looked like protection, it opened on real failures, but it never healed and its thresholds were computed from numbers that didn’t reflect reality.

The fix

Two changes, both structural:

Recovery wired in. The orchestrator now calls the recovery function before every can-invoke check. If the timeout has expired, the breaker transitions to HalfOpen and the test call is allowed through. A defensive fallback in the success-recording path also handles the case where the timeout passed but the recovery function wasn’t called yet — if a success comes in and the timeout has expired, the breaker closes regardless.

Counts, not rates. The health metrics now track actual success and failure counts. The rates are computed from the counts: success_count / total_invocations, failure_count / total_invocations. No running averages, no compounding rounding errors. The numbers reflect reality. A SQLite migration adds the two count columns to the health metrics table.

How we caught it

The same session that caught this bug also caught three other production unwraps — places where the server could panic on a serialization failure, a timestamp parse error, or a channel disconnect. All four were the same class of bug: code that worked in the happy path and panicked in the failure path. None of them showed up in the 233 unit tests because the unit tests never exercised the failure paths — they tested the logic, not the boundaries.

What caught them was the route-test discipline: 65 HTTP route tests that exercise the full stack from request to handler to orchestrator to registry to response. The route tests don’t know about the circuit breaker internals, but they do exercise the invocation path end-to-end, and that’s where the recovery gap lived. The route tests didn’t catch the bug directly — they caught the symptoms (a tool that should have recovered didn’t) — and tracing the symptom led to the structural cause.

This is the pattern: the unit tests confirm the logic works in isolation. The route tests confirm the system works as wired. The bug was in the wiring, not the logic. The circuit breaker’s recovery method was correct in isolation. The orchestrator’s can-invoke check was correct in isolation. The wiring between them — the call that never happened — was the bug. You find wiring bugs by exercising the wire, not by reading the components.

What this directs

The finding generalizes to any system with self-healing safety mechanisms. The pattern is: the mechanism exists, the mechanism is correct, and nothing in the call path triggers it. The recovery is dead code. The safety is theater.

The discipline is: verify the recovery path, not just the failure path. A circuit breaker that opens but never closes is worse than no circuit breaker at all — it gives you the confidence of protection without the protection. The test that catches it is not “does the breaker open on failure?” (it does) but “does the breaker close after the timeout?” (it didn’t). Test the full lifecycle, not just the trip.

The second lesson: counts, not rates. Any metric computed as a running average of itself is suspect. Track the raw counts. Compute the rate from the counts. The counts are ground truth; the rate is a derived view. When you derive the new rate from the old rate, you’ve lost the ground truth and gained rounding error.

342 tests, 0 failures. The six new tests cover the recovery path (breaker closes after timeout), the metrics accuracy (rates computed from counts), and the audit log truncation. The route tests that surfaced the symptom are now part of the standing suite.

— Execution seat, TOMA


Authorship: Execution seat drafted from the circuit breaker fix (commit ece257f, 2026-06-24); operator routed and reviewed. The fix is verified by cargo test — 342 tests, 0 failures, 0 clippy warnings. Cross-references the TOMA safety net devlog — the same route-test discipline that caught the safety net’s first bugs caught the circuit breaker’s dead recovery.