Skip to content

← Concurrency with Threads and asyncio step 36 of 56

Medium Primitives

Retry Within One Shared Deadline

Build the two pieces a request handler needs to keep every retry inside one time budget. Deadline holds the end time. call_with_deadline retries an operation until it succeeds, runs out of retries, or runs out of time.

class Deadline:
    def __init__(self, budget: float) -> None: ...
    def remaining(self) -> float: ...
    def expired(self) -> bool: ...
    def scope(self, limit: float | None = None) -> asyncio.Timeout: ...

async def call_with_deadline[T](
    deadline: Deadline, op: Callable[[], Awaitable[T]], *, retries: int
) -> T

For example, take a 5-second budget and retries=3. An operation that fails twice with RetryableError and then returns 7 runs three times, and call_with_deadline returns 7.

Write Deadline

The driver creates each Deadline inside a running event loop.

  • __init__ stores the end time once, as asyncio.get_running_loop().time() + budget.
  • remaining() returns the seconds left. It goes negative after the deadline.
  • expired() returns True when remaining() is zero or less.
  • scope() returns a new asyncio.Timeout, made with asyncio.timeout_at, that ends at the deadline. Build a new one on every call.
  • scope(limit) ends at whichever comes first, the deadline or limit seconds from now. limit=0 means the scope expires at the next await.

Write call_with_deadline

  • Make one first attempt and at most retries more. retries is zero or more.
  • Before each attempt, check deadline.expired(). If it is True, raise TimeoutError without calling op.
  • Call and await op() inside a fresh deadline.scope(), and return its result.
  • If op() raises RetryableError, try again straight away, without a pause. When no retries are left, re-raise the last RetryableError.
  • Let every other exception propagate without a retry. That includes the deadline's TimeoutError and CancelledError.

What the driver reports

Keep solve as it is. Some scenarios call call_with_deadline with a test operation. Others nest deadline.scope(...) blocks around a call that hangs, and record which except TimeoutError handler ran. The report has these keys:

  • attempts: how many times op ran. When op is slower than the whole budget, this is 1, however many retries are allowed.
  • outcome: "ok", or the name of the exception raised.
  • value: the result, or -1.
  • message: the exception's message. The driver reports every timeout message as "".
  • expired: what deadline.expired() returns at the end.
  • level: which handler caught the timeout in the nesting scenarios, or "none". "outside" means the handler around the async with caught it. "inner" and "outer" name the nested block whose handler ran.