Retries

Transient failures are common enough to deserve a battery: wrap any backend in the retrying variant of its execution model (RetryingSyncBackend / RetryingAsyncBackend / RetryingDeferredBackend) and failed sends are repeated with exponential backoff. The wrapper preserves the execution model — the clients (and the type checker) treat it exactly like the backend it wraps — and the wrapped backend’s hooks run on every attempt, so logs and metrics see the retries:

from action0.client import APIClient
from action0.client import RetryingSyncBackend
from action0.client import RetryPolicy
from action0.client.backends.requests import RequestsBackend

with RequestsBackend() as inner:
    backend = RetryingSyncBackend(inner, RetryPolicy(attempts=5, backoff=0.2))
    client = APIClient(backend, "https://api.example.com/v1")
    item = client.send(GetItem(item_id=42))  # still typed Item

What is retried is the RetryPolicy’s call — by default: TransportErrors (which the backends translate all network failures into) and the transient statuses 408/429/500/502/503/504, for idempotent methods only (methods=None lifts that gate). When the attempt budget is exhausted, the last response is returned (or the last error raised) unchanged — the policy never invents failures.

How long is waited is also the policy’s call: exponential backoff with “full jitter” — each wait is a uniformly random duration up to the exponential delay, so a fleet of clients hitting the same outage does not retry in lockstep (jitter=False waits the exact delays). A Retry-After response header (seconds or HTTP-date form) overrides the computed wait — the server knows best — capped at the policy’s max_backoff; respect_retry_after=False ignores it.

Two execution-model notes: the async wrapper waits with asyncio.sleep by default — under trio pass sleep=trio.sleep; the Twisted wrapper takes a reactor= for its backoff timer (the global reactor by default).