Skip to main content
Microservices · lesson 3 of 8
Microservices intermediate Lesson 3 of 8

Also part of: System Design

Circuit Breaker: Stop Calling a Service That's Already Down

Retrying a dead dependency forever wastes time on every caller. A circuit breaker as a real closed-open-half-open state machine, tripped by real failures.

By SkillByExample · Published

microservices circuit-breaker resilience python

A caller with no circuit breaker keeps calling a dead dependency and keeps paying for the failure — a breaker tracks failures and starts refusing calls before every single one has to fail on its own.

Closed, open, half-open

import time

class CircuitBreaker:
    def __init__(self, failure_threshold=3, reset_timeout=1):
        self.failure_threshold = failure_threshold
        self.reset_timeout = reset_timeout
        self.failure_count = 0
        self.state = "closed"
        self.opened_at = None

    def call(self, func):
        if self.state == "open":
            if time.time() - self.opened_at >= self.reset_timeout:
                self.state = "half-open"
                print("Circuit half-open: trying one test call")
            else:
                print("Circuit open: call rejected without trying")
                return None
        try:
            result = func()
        except ConnectionError as e:
            self.failure_count += 1
            print(f"Call failed ({self.failure_count}/{self.failure_threshold}): {e}")
            if self.failure_count >= self.failure_threshold:
                self.state = "open"
                self.opened_at = time.time()
                print("Circuit OPEN: too many failures")
            return None
        if self.state == "half-open":
            print("Circuit CLOSED: test call succeeded")
        self.state = "closed"
        self.failure_count = 0
        return result

calls = [False, False, False, True]
call_index = 0

def flaky_service():
    global call_index
    ok = calls[call_index]
    call_index += 1
    if not ok:
        raise ConnectionError("downstream unavailable")
    return "response OK"

breaker = CircuitBreaker(failure_threshold=3, reset_timeout=1)
for i in range(5):
    print(f"--- attempt {i + 1} ---")
    result = breaker.call(flaky_service)
    if result:
        print(f"Result: {result}")
    time.sleep(0.5)
$ python circuit_breaker.py
--- attempt 1 ---
Call failed (1/3): downstream unavailable
--- attempt 2 ---
Call failed (2/3): downstream unavailable
--- attempt 3 ---
Call failed (3/3): downstream unavailable
Circuit OPEN: too many failures
--- attempt 4 ---
Circuit open: call rejected without trying
--- attempt 5 ---
Circuit half-open: trying one test call
Circuit CLOSED: test call succeeded
Result: response OK

Attempt 4 never calls flaky_service at all — call_index doesn’t advance, which is exactly the point: the breaker is open, so the caller is protected from a failure it already knows is coming. By attempt 5, reset_timeout (1 second) has elapsed since the circuit opened, so the breaker allows exactly one real call through as a probe. This time flaky_service succeeds (calls[3] is True), so the breaker closes and resets its failure count to zero.

Practice

1. Set failure_threshold=1 so the circuit trips on the very first failure.
def always_fails():
    raise ConnectionError("downstream unavailable")

breaker = CircuitBreaker(failure_threshold=1, reset_timeout=5)
print("--- attempt 1 ---")
breaker.call(always_fails)
print("--- attempt 2 ---")
breaker.call(always_fails)
$ python trip_immediately.py
--- attempt 1 ---
Call failed (1/1): downstream unavailable
Circuit OPEN: too many failures
--- attempt 2 ---
Circuit open: call rejected without trying

With a threshold of 1, there’s no tolerance for even a single blip — every failure is treated as sustained. That’s rarely what you want in production (see the FAQ on choosing a threshold).

2. Raise reset_timeout to 3 seconds and watch the circuit stay open across several 1-second-apart attempts.
def always_fails():
    raise ConnectionError("downstream unavailable")

breaker = CircuitBreaker(failure_threshold=2, reset_timeout=3)
for attempt in range(4):
    print(f"--- attempt {attempt + 1} (t={attempt * 1.0:.0f}s) ---")
    breaker.call(always_fails)
    time.sleep(1)
$ python longer_timeout.py
--- attempt 1 (t=0s) ---
Call failed (1/2): downstream unavailable
--- attempt 2 (t=1s) ---
Call failed (2/2): downstream unavailable
Circuit OPEN: too many failures
--- attempt 3 (t=2s) ---
Circuit open: call rejected without trying
--- attempt 4 (t=3s) ---
Circuit open: call rejected without trying

The circuit opened right after attempt 2, so by attempt 4 (roughly 2 seconds later) it still hasn’t reached the 3-second cooldown — a longer reset_timeout protects the dependency longer but also means legitimate recovery takes longer to be noticed.

3. Make the half-open test call fail too, and confirm the circuit reopens instead of staying half-open.
results = [False, False, False, False, True]
i = 0
def flaky():
    global i
    ok = results[i]
    i += 1
    if not ok:
        raise ConnectionError("still down")
    return "OK"

breaker = CircuitBreaker(failure_threshold=2, reset_timeout=1)
for attempt in range(6):
    print(f"--- attempt {attempt + 1} ---")
    r = breaker.call(flaky)
    if r:
        print(f"Result: {r}")
    time.sleep(0.6)
$ python half_open_fails.py
--- attempt 1 ---
Call failed (1/2): still down
--- attempt 2 ---
Call failed (2/2): still down
Circuit OPEN: too many failures
--- attempt 3 ---
Circuit open: call rejected without trying
--- attempt 4 ---
Circuit half-open: trying one test call
Call failed (3/2): still down
Circuit OPEN: too many failures
--- attempt 5 ---
Circuit open: call rejected without trying
--- attempt 6 ---
Circuit half-open: trying one test call
Call failed (4/2): still down
Circuit OPEN: too many failures

There’s no separate “half-open failure” branch in the code — a failed probe just runs through the same failure-counting path as any other call, and since failure_count is already at the threshold, it reopens on the very next failure. Half-open isn’t a fundamentally different state, just a one-shot exception to “open rejects everything.”

4. Confirm that a success resets the failure count, so it takes 3 consecutive failures to trip, not 3 total.
results = [False, False, True, False, False]
i = 0
def flaky():
    global i
    ok = results[i]
    i += 1
    if not ok:
        raise ConnectionError("downstream unavailable")
    return "OK"

breaker = CircuitBreaker(failure_threshold=3, reset_timeout=5)
for attempt in range(5):
    print(f"--- attempt {attempt + 1} ---")
    r = breaker.call(flaky)
    if r:
        print(f"Result: {r}")
print(f"final state: {breaker.state}")
$ python resets_on_success.py
--- attempt 1 ---
Call failed (1/3): downstream unavailable
--- attempt 2 ---
Call failed (2/3): downstream unavailable
--- attempt 3 ---
Result: OK
--- attempt 4 ---
Call failed (1/3): downstream unavailable
--- attempt 5 ---
Call failed (2/3): downstream unavailable
final state: closed

Five calls, four of them failures, and the circuit never trips — because attempt 3’s success resets failure_count back to zero, the two failures after it start counting from scratch instead of adding to the earlier two.

Next: the same state machine protecting a real HTTP call instead of a plain Python function.

Frequently Asked Questions

What problem does a circuit breaker actually solve?
Without one, every caller of a failing service pays the full cost of trying and failing — a slow timeout, a stack trace, wasted retries — on every single call, for as long as the dependency stays down. A tripped circuit breaker fails instantly instead, which protects the caller's own performance and stops hammering a service that's already struggling.
Why three states instead of just 'working' and 'broken'?
Because a breaker that only ever checks 'is it still broken?' has no way to find out it recovered without sending real traffic again. Half-open is the compromise: after a cooldown, let exactly one call through as a probe. If it succeeds, close the circuit; if it fails, reopen it and wait again.
How is failure_threshold chosen in practice?
Low enough to protect callers quickly, high enough to tolerate a single blip rather than tripping on one unlucky timeout. This lesson uses 3 for a legible example; production breakers often also track a failure *rate* over a rolling window rather than a raw consecutive count, so one failure in a hundred successful calls doesn't trip anything.
Does this replace retries?
No — they solve different problems and are normally used together. A retry handles a single transient blip on an otherwise-healthy dependency; a circuit breaker handles a dependency that's sustained-failing, where retrying would just be more of the same wasted work.