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

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

microservices circuit-breaker http resilience

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.

Frequently Asked Questions

Why does the previous lesson's breaker need a different except clause here?
Because a real network failure doesn't always show up as the exception type you'd expect. `urlopen` raises `urllib.error.URLError` for connection-level failures and `TimeoutError` when a connection succeeds but the response never arrives — both had to be added here, and the FAQ in the previous lesson's intro note already flagged that a caller has to handle more than one failure shape.
The output says 'timed out' instead of 'connection refused' — is that a bug?
No — it's what this lesson's test environment actually does when nothing is listening on a port, confirmed by running it, not assumed. Different networks and firewalls behave differently here; the breaker doesn't care which failure shows up, only that `func()` raised something.
Should every downstream call in a real service go through a circuit breaker?
Not necessarily every one uniformly — calls to critical dependencies with no fallback benefit most. A breaker around a call whose failure is harmless and already has a default (like the API Gateway lesson's fallback values) adds bookkeeping for less benefit.
What's the difference between this and a plain try/except around every call?
A bare try/except still attempts the call every time, paying the full connection/timeout cost on each failure. A breaker in the open state skips the attempt entirely — that's the performance protection a try/except alone doesn't give you.