Microservices · lesson 3 of 8
Beginner
2Intermediate
5Also part of Microservices
9- + Idempotent Consumers: Surviving a Duplicate Delivery from Event-Driven Architecture
- + Transactional Outbox: Committing the Event With the Data from Event-Driven Architecture
- + Scaling & Reliability: Retries With Backoff and Jitter from System Design
- + Rate Limiting: Token Bucket and the Fixed-Window Boundary Bug from System Design
- + Docker Compose from Docker
- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + OpenTelemetry Buffers Spans — a SIGKILL Can Lose Them from Monitoring & Observability
- + Events vs Requests: Why the Order Service Doesn't Wait for Email from Event-Driven Architecture
- + Contract Testing with OpenAPI, and Running in CI from API Testing
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
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.