~/blog/powering-your-software-architecture-with-circuit-breakers-protecting-your-system-from-cascading

Circuit Breakers: Containing Cascading Failures

How a circuit breaker opens, isolates a failing dependency and closes again, why that stops a failure cascading, and how to implement one.

Krzysztof Słomka5 min readupdated

Circuit breaker pattern protecting a system from cascading failures

A circuit breaker's job is narrow and specific: stop a slow or failing dependency from exhausting every thread in the caller that's waiting on it. Without one, a payment provider timing out at 30 seconds doesn't just make checkout slow, it ties up every worker calling it until the whole service has none left for anything else, including the requests that had nothing to do with payments. A circuit breaker is the mechanism that notices and stops calling.

This covers the three states a breaker actually has and why "open" and "closed" alone aren't enough, the thresholds that decide when it trips, what the caller does with a request while the breaker is open, the difference between a breaker and a plain timeout, and where a breaker will not save you.

The electrical metaphor, and where it stops helping#

The name comes from household electrical breakers: too much current, the breaker trips, the circuit opens, the damage downstream is contained. That part of the metaphor holds. Where it stops helping is the half-open state, which has no electrical equivalent and does most of the pattern's actual work.

failure threshold exceeded

timeout elapses

probe succeeds

probe fails

Closed

Open

HalfOpen

Closed is normal operation. Calls pass through to the dependency, and the breaker counts failures in a rolling window. Open means the breaker has stopped forwarding calls entirely; it fails fast, immediately, without touching the dependency at all. Half-open is the state that makes the pattern recoverable rather than a one-way kill switch: after a cooldown, the breaker lets a small number of probe requests through. If they succeed, it closes and resumes normal traffic. If they fail, it reopens and waits again.

Without half-open, a breaker that trips stays tripped until someone restarts the process, which turns a transient blip into a manual-intervention incident.

A minimal implementation#

final class CircuitBreaker
{
    private int $failureCount = 0;
    private State $state = State::Closed;
    private ?\DateTimeImmutable $openedAt = null;

    public function __construct(
        private readonly int $failureThreshold = 5,
        private readonly int $cooldownSeconds = 30,
    ) {}

    public function call(callable $operation): mixed
    {
        if ($this->state === State::Open && !$this->cooldownElapsed()) {
            throw new CircuitOpenException();
        }

        if ($this->state === State::Open) {
            $this->state = State::HalfOpen;
        }

        try {
            $result = $operation();
            $this->onSuccess();
            return $result;
        } catch (\Throwable $e) {
            $this->onFailure();
            throw $e;
        }
    }

    private function onFailure(): void
    {
        $this->failureCount++;
        if ($this->failureCount >= $this->failureThreshold || $this->state === State::HalfOpen) {
            $this->state = State::Open;
            $this->openedAt = new \DateTimeImmutable();
        }
    }

    private function onSuccess(): void
    {
        $this->failureCount = 0;
        $this->state = State::Closed;
    }

    private function cooldownElapsed(): bool
    {
        return $this->openedAt !== null
            && (new \DateTimeImmutable())->getTimestamp() - $this->openedAt->getTimestamp() >= $this->cooldownSeconds;
    }
}

Two details matter for correctness, not just style. First, a single failure while half-open reopens the breaker immediately rather than waiting for the full threshold again. Half-open is a probe, not a second chance at the same leniency that got the breaker here. Second, failureThreshold and cooldownSeconds are the two numbers that decide whether the breaker protects you or gets in your way: too low a threshold trips on noise, too long a cooldown keeps failing fast long after the dependency has recovered. There is no universal correct value for either; they get tuned against the dependency's own actual failure and recovery behaviour, not copied from a blog post.

Not the same thing as a timeout#

A timeout bounds how long one call waits. A circuit breaker decides whether to attempt the call at all, based on the history of recent calls. The two are not substitutes; they're layered. A timeout without a breaker still lets every caller individually wait out the full timeout on every request to a dead dependency, which is the thread-exhaustion scenario the breaker exists to prevent. A breaker without a timeout can still have its probe call hang indefinitely in half-open. Production implementations, Hystrix historically, Resilience4j now, pair a breaker with a timeout and usually a bulkhead (a thread pool or semaphore limiting concurrent calls per dependency) rather than shipping either alone.

What a caller does while the breaker is open#

An open breaker fails fast, which only helps if the caller has something better to do with that failure than propagate it as a 500. Three options, in order of how much they help the end user: return a cached or default value if one exists and is acceptable (show the last known price, skip the recommendation widget), degrade the feature visibly rather than failing the whole request, or, if neither applies, fail fast and loud enough that an on-call engineer notices before a customer does. Failing fast with no fallback and no alert just moves the outage from "slow" to "instant and invisible to the team."

Where this will not save you#

A circuit breaker protects the caller from a failing dependency. It does nothing for the dependency itself, and it does nothing if the failure is on your own write path rather than a downstream call, for instance a database that's up but returning wrong data. It's also not a substitute for capacity planning: a breaker that trips constantly under normal peak load isn't protecting you from a failure, it's reporting that the dependency's capacity and your traffic no longer match, and the fix is scaling or load-shedding, not retuning the threshold until the alerts stop.

$ related posts