A resilient HTTP client¶
The problem¶
Your service calls other services. They return 503 during deploys, drop connections, answer slowly, and sometimes stay down for minutes. A bare requests.get turns every one of those into an error for your user, and a naive retry loop makes an outage worse by hammering the dependency.
You want a client that:
- retries transient failures (connection resets, timeouts, 5xx) but never 4xx,
- waits longer between attempts and adds jitter so a fleet of clients does not retry in lockstep,
- stops calling a host that is clearly down, per host, so one bad dependency does not block the others,
- never exceeds a request budget per host,
- has an overall deadline.
The code¶
The network is a scripted FakeTransport, so this page runs offline and the behaviour is deterministic. The client only needs an object with send(url, *, timeout) -> Response; mapping it to httpx or requests is a ten-line adapter.
import threading
from urllib.parse import urlsplit
from python_wrap import CircuitBreaker, CircuitOpenError, rate_limit, retry, timeout
from python_wrap.testing import fake_clock
class Response:
def __init__(self, status: int, body: str = "") -> None:
self.status = status
self.body = body
class ServerError(Exception):
"""A 5xx answer: worth retrying."""
class ClientError(Exception):
"""A 4xx answer: our fault, retrying cannot help."""
TRANSIENT = (ConnectionError, TimeoutError, ServerError)
def host_of(url: str) -> str:
return urlsplit(url).netloc
class FakeTransport:
"""Plays back a script per host: an int is a status code, an exception is raised."""
def __init__(self, script: dict[str, list[object]]) -> None:
self.script = script
self.calls: list[str] = []
def send(self, url: str, *, timeout: float) -> Response:
self.calls.append(url)
steps = self.script[host_of(url)]
step = steps.pop(0) if len(steps) > 1 else steps[0] # the last step repeats
if isinstance(step, BaseException):
raise step
assert isinstance(step, int)
return Response(step, f"body of {url}")
class ResilientClient:
def __init__(self, transport: FakeTransport) -> None:
self._transport = transport
self._breakers: dict[str, CircuitBreaker] = {}
self._lock = threading.Lock()
def breaker_for(self, host: str) -> CircuitBreaker:
with self._lock:
if host not in self._breakers:
self._breakers[host] = CircuitBreaker(
failure_threshold=3, recovery_timeout=30, on=TRANSIENT, name=host
)
return self._breakers[host]
@timeout(30) # overall deadline, retries included
@retry(max_attempts=4, delay=0.2, backoff=2, max_delay=5, jitter=0.1, on=TRANSIENT)
@rate_limit(5, 1.0, block=True, key=lambda self, url: host_of(url))
def get(self, url: str) -> Response:
# CircuitOpenError is not in TRANSIENT, so an open circuit is not retried.
return self.breaker_for(host_of(url)).call(self._send, url)
def _send(self, url: str) -> Response:
response = self._transport.send(url, timeout=5.0)
if response.status >= 500:
raise ServerError(f"{response.status} from {url}")
if response.status >= 400:
raise ClientError(f"{response.status} from {url}")
return response
def main() -> None:
transport = FakeTransport(
{
"api.example.com": [503, ConnectionError("reset"), 200],
"down.example.com": [503],
"missing.example.com": [404],
}
)
client = ResilientClient(transport)
with fake_clock() as clock: # backoff sleeps are instant and recorded
# 1. Transient failures are retried with growing, jittered waits.
assert client.get("https://api.example.com/users").status == 200
print("retried twice, waits:", [round(s, 2) for s in clock.sleeps])
# 2. A 404 is not transient: it is raised at once, not retried.
before = len(transport.calls)
try:
client.get("https://missing.example.com/x")
except ClientError:
assert len(transport.calls) == before + 1
print("404 raised without retrying")
# 3. A dead host opens its breaker; other hosts are unaffected.
try:
client.get("https://down.example.com/a")
except CircuitOpenError as exc:
print(f"circuit for down.example.com open, retry in {exc.retry_after:.0f}s")
before = len(transport.calls)
try:
client.get("https://down.example.com/b")
except CircuitOpenError:
assert len(transport.calls) == before # rejected without touching the network
assert client.get("https://api.example.com/users").status == 200
# 4. After the recovery timeout one trial call is allowed through.
transport.script["down.example.com"] = [200]
clock.advance(31)
assert client.get("https://down.example.com/c").status == 200
print("circuit closed again after recovery")
# 5. The rate limit waits (it does not fail) once a host's budget is used up.
clock.sleeps.clear()
for i in range(7):
client.get(f"https://api.example.com/page/{i}")
assert any(s > 0 for s in clock.sleeps)
print("rate limit waited:", [round(s, 2) for s in clock.sleeps])
main()
How it works¶
The decorators on get read from the bottom up, because the innermost one runs first on each attempt:
@rate_limit(5, 1.0, block=True, key=...)allows 5 calls per second per host.key=receives the call's arguments (includingself) and returns the bucket; here it is the host. Withblock=Truethe call waits (it does not fail), and in async code it waits withawait, so the event loop stays free.@retry(..., on=TRANSIENT)wraps the rate limiter, so every attempt, including retries, counts against the host's budget. The wait before attemptn + 1ismin(delay * backoff ** (n - 1), max_delay) + uniform(0, jitter): roughly 0.2 s, 0.4 s, 0.8 s, each plus up to 0.1 s of jitter.on=limits retries to the listed exception types; aClientError(4xx) propagates immediately.@timeout(30)is outermost, so it bounds the whole call, retries and waits included.
The circuit breaker is not a decorator on get because there must be one breaker per host, created on demand. breaker.call(func, *args) runs a call through a breaker without decorating anything. Because on=TRANSIENT is set on the breaker too, a 404 does not count as a failure: only problems that suggest the host is unhealthy trip it.
Notice what happens when a host is down. The first three failed attempts open its breaker (failure_threshold=3). The fourth attempt raises CircuitOpenError, which is not in TRANSIENT, so retry lets it through instead of waiting out more backoff, and later calls fail instantly with exc.retry_after telling the caller how long until the circuit tries again.
Pitfalls¶
Rate-limit state belongs to the function, not the instance
@rate_limit on a method keeps its buckets on the decorated function, so every ResilientClient instance shares them (separated only by your key=). That is usually what you want for "5 calls per second to this host from this process", but it surprises tests. Use disable_rate_limits() or a unique host name per test. For a limit per instance, include id(self) in the key.
timeout on sync code uses signals
On Unix, in the main thread, @timeout interrupts the call with SIGALRM. In worker threads, or on Windows, it falls back to a helper thread and cannot stop the work, only stop waiting for it. Set a real timeout on the HTTP library as well (the timeout=5.0 passed to the transport above); the decorator is the overall budget, not a replacement. In async def code @timeout cancels the coroutine properly.
Only retry what is safe to repeat
Retrying a GET is safe. Retrying a POST that charges a card is not, unless the endpoint is idempotent (for example it accepts an idempotency key). Give write methods a narrower on= (for example only ConnectionError raised before the request was sent) or no retry at all.
Honouring Retry-After
retry computes its waits from delay, backoff and jitter; it cannot take a delay from the exception. If a server sends Retry-After and you must obey it, catch that case yourself and sleep, or raise a non-retried exception for it.
- Keep
failure_thresholdandrecovery_timeouthonest. Three failures out of three calls is a dead host; three failures out of 3000 is noise. The breaker counts consecutive failures, so a single success resets it. - Do not create a new
CircuitBreakeron every request: it would never see enough failures. Keep them in a dict (as above) or module-level.
Using httpx or requests¶
Everything above the transport stays the same. Write an adapter that turns the library's exceptions into the built-in ones the client retries on.
import httpx
class HttpxTransport:
def __init__(self) -> None:
self._client = httpx.Client()
def send(self, url: str, *, timeout: float) -> Response:
try:
r = self._client.get(url, timeout=timeout)
except httpx.TimeoutException as exc:
raise TimeoutError(str(exc)) from exc
except httpx.TransportError as exc: # connect errors, resets, DNS
raise ConnectionError(str(exc)) from exc
return Response(r.status_code, r.text)
client = ResilientClient(HttpxTransport())
import requests
class RequestsTransport:
def __init__(self) -> None:
self._session = requests.Session()
def send(self, url: str, *, timeout: float) -> Response:
try:
r = self._session.get(url, timeout=timeout)
except requests.Timeout as exc:
raise TimeoutError(str(exc)) from exc
except requests.ConnectionError as exc:
raise ConnectionError(str(exc)) from exc
return Response(r.status_code, r.text)
For httpx.AsyncClient make get and _send async def and use await breaker.call(...): every decorator used here works unchanged on coroutine functions.
Test it¶
The decorators wait and remember, so tests need the helpers from python_wrap.testing: fake_clock makes sleeps instant and records them, spy_on counts transport calls and disable_rate_limits lets calls through limiters. (fast_mode skips waits without recording them; see Testing code that uses python-wrap.)
import threading
from urllib.parse import urlsplit
from python_wrap import CircuitBreaker, CircuitOpenError, rate_limit, retry, timeout
from python_wrap.testing import fake_clock
class Response:
def __init__(self, status: int, body: str = "") -> None:
self.status = status
self.body = body
class ServerError(Exception):
"""A 5xx answer: worth retrying."""
class ClientError(Exception):
"""A 4xx answer: our fault, retrying cannot help."""
TRANSIENT = (ConnectionError, TimeoutError, ServerError)
def host_of(url: str) -> str:
return urlsplit(url).netloc
class FakeTransport:
"""Plays back a script per host: an int is a status code, an exception is raised."""
def __init__(self, script: dict[str, list[object]]) -> None:
self.script = script
self.calls: list[str] = []
def send(self, url: str, *, timeout: float) -> Response:
self.calls.append(url)
steps = self.script[host_of(url)]
step = steps.pop(0) if len(steps) > 1 else steps[0] # the last step repeats
if isinstance(step, BaseException):
raise step
assert isinstance(step, int)
return Response(step, f"body of {url}")
class ResilientClient:
def __init__(self, transport: FakeTransport) -> None:
self._transport = transport
self._breakers: dict[str, CircuitBreaker] = {}
self._lock = threading.Lock()
def breaker_for(self, host: str) -> CircuitBreaker:
with self._lock:
if host not in self._breakers:
self._breakers[host] = CircuitBreaker(
failure_threshold=3, recovery_timeout=30, on=TRANSIENT, name=host
)
return self._breakers[host]
@timeout(30) # overall deadline, retries included
@retry(max_attempts=4, delay=0.2, backoff=2, max_delay=5, jitter=0.1, on=TRANSIENT)
@rate_limit(5, 1.0, block=True, key=lambda self, url: host_of(url))
def get(self, url: str) -> Response:
# CircuitOpenError is not in TRANSIENT, so an open circuit is not retried.
return self.breaker_for(host_of(url)).call(self._send, url)
def _send(self, url: str) -> Response:
response = self._transport.send(url, timeout=5.0)
if response.status >= 500:
raise ServerError(f"{response.status} from {url}")
if response.status >= 400:
raise ClientError(f"{response.status} from {url}")
return response
import pytest
from python_wrap.testing import disable_rate_limits, spy_on
def make(script: dict[str, list[object]]) -> tuple[ResilientClient, FakeTransport]:
transport = FakeTransport(script)
return ResilientClient(transport), transport
def test_retries_with_exponential_backoff() -> None:
client, transport = make({"a.test": [503, ConnectionError("reset"), 200]})
with fake_clock() as clock, disable_rate_limits():
assert client.get("https://a.test/x").status == 200
assert len(transport.calls) == 3
first, second = clock.sleeps
assert 0.2 <= first <= 0.3 # delay 0.2 + up to 0.1 jitter
assert 0.4 <= second <= 0.5 # backoff doubles it
def test_client_errors_are_not_retried() -> None:
client, transport = make({"b.test": [404]})
with spy_on(transport, "send") as send, fake_clock() as clock, disable_rate_limits():
with pytest.raises(ClientError):
client.get("https://b.test/missing")
assert send.call_count == 1
assert clock.sleeps == []
def test_open_circuit_fails_fast_and_is_per_host() -> None:
client, transport = make({"down.test": [503], "up.test": [200]})
with fake_clock() as clock, disable_rate_limits():
with pytest.raises(CircuitOpenError):
client.get("https://down.test/x")
calls_when_open = len(transport.calls)
with pytest.raises(CircuitOpenError):
client.get("https://down.test/y")
assert len(transport.calls) == calls_when_open # no network traffic
assert client.get("https://up.test/").status == 200 # other hosts unaffected
transport.script["down.test"] = [200]
clock.advance(31) # past recovery_timeout
assert client.get("https://down.test/z").status == 200
def test_rate_limit_waits_instead_of_failing() -> None:
client, _ = make({"rate.test": [200]})
with fake_clock() as clock: # rate limiting stays on here
for i in range(6):
client.get(f"https://rate.test/{i}")
assert any(s > 0.5 for s in clock.sleeps)
# pytest collects the test_ functions itself; this loop only lets the page run them directly.
for _name, _test in list(globals().items()):
if _name.startswith("test_"):
_test()
If you use pytest, run it as usual; in a larger suite enable the plugin once (pytest_plugins = ["python_wrap.testing"] in conftest.py) to get the pw_fake_clock and pw_fast_mode fixtures instead of the context managers.