A circuit breaker without a half-open state never recovers

A breaker that opens on failure and closes on a timer either flaps or stays open, and the half-open state is what makes the recovery decision evidence-based.

// closed    → requests pass, failures counted
// open      → requests fail immediately, no call made
// half-open → ONE request passes. success closes,
//             failure reopens the timer.

match ($this->state) {
    State::Closed   => $this->callAndCount($fn),
    State::Open     => $this->expired()
        ? $this->attemptReset($fn)
        : throw new CircuitOpen(),
    State::HalfOpen => throw new CircuitOpen(),
};

The half-open state must admit exactly one request, which requires a lock in any multi-process deployment — several workers each sending a probe is the thundering herd the breaker was supposed to prevent. Thresholds are better expressed as a failure rate over a window than as a consecutive count, because a service failing one request in three never trips a consecutive counter and is unambiguously unhealthy.