Skip to content
← All articles

Timeouts: asyncio.timeout Supersedes wait_for

Per-call timeouts do not compose; deadlines do. Why TimeoutError can only be caught outside the block, how nesting attributes correctly, and the retry rule everyone misses.

A request has a one-second SLA. It calls a downstream service with a two-second timeout, and retries up to five times. Every individual call is “within timeout”. The request takes twelve seconds.

That is not a bug in any one line. It is the absence of a budget. Per-call timeouts do not compose; deadlines do.

asyncio.timeout and what it actually does

Python 3.11 added the context-manager form:

async with asyncio.timeout(1.5):
    await fetch()

On expiry it cancels the current task and, in __aexit__, converts that CancelledError into TimeoutError. Two consequences follow directly from that mechanism, and both surprise people:

The TimeoutError can only be caught outside the block. Inside, what is propagating is a CancelledError — the conversion has not happened yet. So

async with asyncio.timeout(1.5):
    try:
        await fetch()
    except TimeoutError:      # never runs
        return CACHED

does nothing, and the try has to go around the async with.

It only works inside a Task. The mechanism is Task.cancel(), so there has to be a task to cancel. Under asyncio.run(main()) you are always inside one, so this rarely bites — but a bare loop callback is not a task, and neither is code running on a thread with no loop at all.

timeout_at(when) is the same thing against an absolute point on loop.time(), and it is the one you want for budgets: every nested scope computes its own when from the same absolute deadline, so nesting can only ever tighten it. The object also exposes when(), reschedule(new_when) and expired(), which is what makes “extend the deadline now that we know the payload is large” expressible.

💡asyncio.timeout(1.0) wrapping a block that itself contains asyncio.timeout(5.0). Which fires, and what does the inner scope see? click to reveal

The outer one, at one second — and the inner scope explicitly does not interfere.

The bookkeeping is Task.cancelling() / uncancel(), added in 3.11 for exactly this. Each scope records the task’s cancelling count on entry. When the outer scope expires it calls task.cancel(), which raises the count. The inner scope’s __aexit__ sees a CancelledError, calls uncancel(), and finds the count is still above what it recorded on entry — so this cancellation was not its own, and it lets the CancelledError through untouched. The outer scope then finds the count back at its own baseline, and converts to TimeoutError.

So: timeouts nest correctly and attribute correctly, and the exception surfaces at the level that actually expired. That is worth knowing, because it means you can safely put a per-call timeout inside a per-request budget and the error you get tells you which one you blew.

wait_for is not deprecated, and is not the tool for this

asyncio.wait_for(aw, timeout) still works and is still correct. Python 3.12 reimplemented it on top of asyncio.timeout, so the semantics are now the same. It is simply less flexible: it wraps exactly one awaitable, has no absolute-deadline form, cannot be rescheduled, and cannot wrap a block.

Use wait_for for “this one call, this one bound”. Use timeout/timeout_at for anything with a shape.

The budget pattern

class Deadline:
    def __init__(self, budget: float) -> None:
        self._at = asyncio.get_running_loop().time() + budget

    def remaining(self) -> float:
        return self._at - asyncio.get_running_loop().time()

    def scope(self, limit: float | None = None) -> asyncio.Timeout:
        when = self._at
        if limit is not None:
            when = min(when, asyncio.get_running_loop().time() + limit)
        return asyncio.timeout_at(when)

Every scope clamps to the same absolute point, so nesting can only tighten. Retries check remaining() before starting rather than after failing. And the whole call graph can carry one object instead of a float that means something different at every level.

💡Retry with exponential backoff, inside a deadline. What is the extra rule most implementations miss? click to reveal

That the sleep between attempts is part of the budget, and that starting an attempt you cannot finish is worse than not starting it.

The usual loop is: attempt, fail, await asyncio.sleep(backoff), attempt again. With a 1-second budget, a 0.8-second first attempt and a 0.5-second backoff, the second attempt starts at 1.3 seconds — already past the deadline — and is immediately cancelled. You paid for the backoff, you paid for the connection setup, and you got nothing.

Two rules fix it. Before sleeping, clamp: await asyncio.sleep(min(backoff, deadline.remaining())), and if remaining() is already non-positive, do not sleep at all. Before attempting, check deadline.expired() and give up with a TimeoutError rather than starting work that cannot complete.

The second rule is the one that shows up in this item’s tests: an operation that consumes the whole budget on its first attempt must produce exactly one attempt, no matter how many retries were configured.