Microservices · lesson 4 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
Circuit Breaker Around a Real Downstream Call
The same closed-open-half-open state machine, now protecting a real HTTP call to a service that's genuinely down — not a Python function raising on cue.
By SkillByExample · Published
Wrapping a plain function is one thing — wrapping a real HTTP call means the breaker has to recognize failures it can’t script in advance.
Protecting a call to a service that’s actually down
import http.server
import threading
import time
import urllib.error
import urllib.request
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 real call")
else:
print("Circuit open: call rejected without trying")
return None
try:
result = func()
except (urllib.error.URLError, TimeoutError) as e:
self.failure_count += 1
reason = e.reason if isinstance(e, urllib.error.URLError) else str(e)
print(f"Call failed ({self.failure_count}/{self.failure_threshold}): {reason}")
if self.failure_count >= self.failure_threshold:
self.state = "open"
self.opened_at = time.time()
print("Circuit OPEN: payment-service looks down")
return None
if self.state == "half-open":
print("Circuit CLOSED: real call succeeded")
self.state = "closed"
self.failure_count = 0
return result
class OKHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"payment processed")
def log_message(self, *args):
pass
def call_payment_service():
response = urllib.request.urlopen("http://localhost:8002/pay", timeout=0.5)
return response.read().decode()
breaker = CircuitBreaker(failure_threshold=3, reset_timeout=1)
server = None
for i in range(5):
print(f"--- attempt {i + 1} ---")
if i == 4 and server is None:
server = http.server.HTTPServer(("localhost", 8002), OKHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
result = breaker.call(call_payment_service)
if result:
print(f"Result: {result}")
time.sleep(0.5)
if server:
server.shutdown()
$ python circuit_breaker_http.py
--- attempt 1 ---
Call failed (1/3): timed out
--- attempt 2 ---
Call failed (2/3): timed out
--- attempt 3 ---
Call failed (3/3): timed out
Circuit OPEN: payment-service looks down
--- attempt 4 ---
Circuit open: call rejected without trying
--- attempt 5 ---
Circuit half-open: trying one real call
Circuit CLOSED: real call succeeded
Result: payment processed
Nothing is listening on port 8002 for the first three attempts, so urlopen fails — and on
this network, that failure surfaces as e.reason == "timed out", not the textbook “connection
refused.” That failure happens while connecting, which urlopen always wraps as URLError.
A downstream that accepts the connection but is too slow to respond fails differently: the
timeout fires inside getresponse(), which raises a bare TimeoutError that URLError does
not cover — that’s why call catches both, not just URLError alone. By attempt 5, the server
has actually started (the script starts it right before that attempt) and the half-open probe
succeeds against a real listening service.
Practice
1. Make the downstream service return HTTP 500 instead of not listening, and catch that as a failure too.
import http.server
import threading
import time
import urllib.error
import urllib.request
class FlakyHandler(http.server.BaseHTTPRequestHandler):
calls = 0
def do_GET(self):
FlakyHandler.calls += 1
if FlakyHandler.calls <= 2:
self.send_response(500)
self.end_headers()
else:
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *args):
pass
server = http.server.HTTPServer(("localhost", 8010), FlakyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
def call_it():
response = urllib.request.urlopen("http://localhost:8010/status", timeout=1)
return response.read().decode()
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=1)
for i in range(4):
print(f"--- attempt {i + 1} ---")
r = breaker.call(call_it)
if r:
print(f"Result: {r}")
time.sleep(0.5)
server.shutdown()
$ python http_500_as_failure.py
--- attempt 1 ---
Call failed (1/2): Internal Server Error
--- attempt 2 ---
Call failed (2/2): Internal Server Error
Circuit OPEN: payment-service looks down
--- attempt 3 ---
Circuit open: call rejected without trying
--- attempt 4 ---
Circuit half-open: trying one real call
Circuit CLOSED: real call succeeded
Result: ok
urlopen raises urllib.error.HTTPError for a non-2xx status — and since HTTPError is a
subclass of URLError, the exact same except urllib.error.URLError clause already catches
it without any change to the breaker itself. e.reason even resolves to the HTTP reason
phrase, “Internal Server Error,” instead of a connection-level message.
2. Run two independent breakers side by side — one for a service that's down, one for a healthy one.
class DownHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(5)
try:
self.send_response(200)
self.end_headers()
self.wfile.write(b"late")
except OSError:
pass
def log_message(self, *args):
pass
class HealthyHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *args):
pass
down_server = http.server.HTTPServer(("localhost", 8011), DownHandler)
healthy_server = http.server.HTTPServer(("localhost", 8012), HealthyHandler)
threading.Thread(target=down_server.serve_forever, daemon=True).start()
threading.Thread(target=healthy_server.serve_forever, daemon=True).start()
time.sleep(0.2)
def call_down():
return urllib.request.urlopen("http://localhost:8011/x", timeout=0.3).read()
def call_healthy():
return urllib.request.urlopen("http://localhost:8012/x", timeout=0.3).read().decode()
payments_breaker = CircuitBreaker(failure_threshold=2, reset_timeout=5)
shipping_breaker = CircuitBreaker(failure_threshold=2, reset_timeout=5)
for i in range(3):
print(f"--- round {i + 1} ---")
payments_breaker.call(call_down)
r = shipping_breaker.call(call_healthy)
if r:
print(f"shipping result: {r}")
print(f"payments breaker state: {payments_breaker.state}")
print(f"shipping breaker state: {shipping_breaker.state}")
down_server.shutdown()
healthy_server.shutdown()
$ python independent_breakers.py
--- round 1 ---
Call failed (1/2): timed out
shipping result: ok
--- round 2 ---
Call failed (2/2): timed out
Circuit OPEN: payment-service looks down
shipping result: ok
--- round 3 ---
Circuit open: call rejected without trying
shipping result: ok
payments breaker state: open
shipping breaker state: closed
The shipping calls succeed on every round, completely unaffected by the payments breaker
tripping open — each CircuitBreaker instance tracks its own state. Sharing one breaker
across unrelated dependencies would let one dependency’s outage falsely trip protection for a
completely healthy one. DownHandler accepts the connection and only then sleeps, so this
failure surfaces as the bare TimeoutError from getresponse() described above, not a
connect-stage URLError — without the except (urllib.error.URLError, TimeoutError) fix in
the main example, this script would crash instead of demonstrating anything.
3. Simulate a flapping service that fails, recovers, then verify the circuit reopens and recloses correctly.
class FlappingHandler(http.server.BaseHTTPRequestHandler):
calls = 0
def do_GET(self):
FlappingHandler.calls += 1
pattern = [True, True, False, False, True]
ok = pattern[(FlappingHandler.calls - 1) % len(pattern)]
if ok:
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
else:
self.send_response(503)
self.end_headers()
def log_message(self, *args):
pass
server = http.server.HTTPServer(("localhost", 8013), FlappingHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(0.2)
def call_it():
response = urllib.request.urlopen("http://localhost:8013/x", timeout=1)
return response.read().decode()
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=0.5)
for i in range(6):
print(f"--- attempt {i + 1} (breaker state: {breaker.state}) ---")
r = breaker.call(call_it)
if r:
print(f"Result: {r}")
time.sleep(0.4)
server.shutdown()
$ python flapping_service.py
--- attempt 1 (breaker state: closed) ---
Result: ok
--- attempt 2 (breaker state: closed) ---
Result: ok
--- attempt 3 (breaker state: closed) ---
Call failed (1/2): Service Unavailable
--- attempt 4 (breaker state: closed) ---
Call failed (2/2): Service Unavailable
Circuit OPEN: payment-service looks down
--- attempt 5 (breaker state: open) ---
Circuit open: call rejected without trying
--- attempt 6 (breaker state: open) ---
Circuit half-open: trying one real call
Circuit CLOSED: real call succeeded
Result: ok
The service genuinely flaps (up, up, down, down, up) and the breaker’s state trace matches it
exactly — printing breaker.state at the top of each loop iteration makes the transition
visible instead of only inferable from the messages below it.
Next: aggregating calls to more than one downstream service into a single response, with a fallback when one of them is too slow.