open atlas
↑ Back to track
Python for JS/TS developers PY · 11 · 03

Timeouts and shielding: scoped cancel with TimeoutError conversion, deadline propagation, and shield as half a tool

asyncio.timeout cancels its body at expiry and converts CancelledError to TimeoutError at the boundary; cancel-count bookkeeping makes nesting compose. Propagate deadlines, not per-hop timeouts. shield blocks outer cancel but inner work runs detached — pair it with a timeout.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The graceful shutdown was a point of pride: on SIGTERM the metrics exporter cancelled its workers, then flushed the last buffer to the collector — and the flush was wrapped in asyncio.shield(), because an earlier incident had taught the team that shutdown cancellation was eating the final batch. The shield worked. Too well. One Tuesday the collector pod died first, the TCP connection became a black hole, and the shielded flush awaited a write that would never complete. Cancellation could not touch it — that was the point of the shield — so the exporter hung at full grace period, got SIGKILL, and lost the buffer anyway, now plus twenty minutes of node drain delay across the fleet. The postmortem line that stuck: shield decides who may not interrupt the work; it says nothing about how long the work may take. Shield without a timeout is not protection — it is an unbounded hostage situation. This lesson is about the pair: scoped cancellation that converts to TimeoutError at a boundary, deadlines that travel down call chains, and shield as exactly one half of a tool.

asyncio.timeout is a scoped cancel, not a stopwatch

When you reach for a timeout, ask yourself: do you want to bound when the work ends, or who may interrupt it? The answer decides whether you need asyncio.timeout, shield, or both — and confusing them is how a shielded flush becomes an unbounded hostage. async with asyncio.timeout(5): (3.11+) does three things you can now name precisely from lessons 1 and 2. It schedules a call_at timer on the loop heap; at expiry, that timer cancels the current task — an ordinary injected CancelledError, landing at the body’s next suspension point; and at the scope’s exit, if the arriving CancelledError was its own doing, it converts it to TimeoutError. Outside the scope you see a clean timeout; inside, the body experienced plain cancellation — so all of lesson 2 applies verbatim inside a timeout scope: finally blocks run, cleanup must be bounded, swallowing breaks everything.

The conversion decision is the deep part: how does the scope know the cancellation was its own? Cancel-count bookkeeping. Each cancel() increments the task’s cancelling count; when the scope’s timer fired the cancel, the scope calls uncancel() on exit — if the count drops back to zero, the cancellation belonged to this scope and becomes TimeoutError; if it stays positive, an outer cancellation (or outer timeout) is also in flight, and the scope re-raises CancelledError untouched so the outer claim wins. This is what makes nesting compose:

async with asyncio.timeout(10):        # outer budget
    async with asyncio.timeout(2):     # inner, stricter
        await fetch()                  # takes 5 s
# inner scope: its timer fired, its uncancel() zeroes the count -> TimeoutError
# outer scope: never fired, sees an ordinary exception fly through, stays armed

wait_for is the older sibling: it wraps a single awaitable (creating a Task for a bare coroutine), where timeout() scopes a block. Historically wait_for had real edge cases around the result-arrives-while-cancelling race — completed work could be dropped, or cancellation leaked; since 3.12 it is reimplemented on top of asyncio.timeout, inheriting the count bookkeeping. Modern code prefers the scope: it composes, it covers multiple awaits, and it does not force a Task allocation.

Quiz

Nested scopes: timeout(10) outside, timeout(2) inside, and the awaited fetch takes 5 s. What surfaces, and from which boundary?

Deadlines travel; timeouts do not

A constant per-hop timeout is the junior version of the idea. A request that must answer in 2 s and makes three sequential downstream calls cannot give each call 2 s — the budgets must share the deadline, each hop computing the remainder. This is the same pattern Go institutionalizes as context deadlines (the go track’s context-discipline lesson); in asyncio you carry a deadline and derive scopes from it — asyncio.timeout_at(when) takes an absolute loop-clock time for exactly this:

async def call_with_deadline(deadline, op):
    loop = asyncio.get_running_loop()
    if deadline - loop.time() <= 0:
        raise TimeoutError("budget exhausted before the call")
    async with asyncio.timeout_at(deadline):   # absolute, shared, shrinking
        return await op()

async def handler():
    deadline = asyncio.get_running_loop().time() + 2.0   # client-facing budget
    user = await call_with_deadline(deadline, fetch_user)
    orders = await call_with_deadline(deadline, fetch_orders)  # gets the REMAINDER
    return render(user, orders)

The classic misordering incident: an outer timeout(5) around a retry loop whose per-attempt budget is 10 s with three attempts. The outer scope always fires mid-attempt-one; the per-attempt timeout never trips; the retry loop is unreachable code wearing a straight face — metrics show retries == 0 forever while the postmortem assumed three attempts. The rule worth pinning: budgets must nest strictly — per-attempt under per-request under client-facing — and the only structure that enforces it automatically is deriving every inner scope from one shrinking deadline.

shield: who may not interrupt — not how long

asyncio.shield(thing) wraps the awaitable in a Task and gives you a guarded await on it. When outer cancellation arrives, it hits the shield wait, not the inner task: your await shield(...) raises CancelledError immediately, while the inner task keeps running, detached. That sentence contains both the feature and the failure mode. The feature: a commit already in flight, a write-behind cache flush — work whose abandonment costs more than its completion — survives the cancellation storm around it. The failure mode is the orphan problem: after the shield wait dies, who awaits the inner task? If you did not keep a reference, its result and its exceptions vanish into the task graveyard (and “Task exception was never retrieved” logs at best). And the Hook is the second failure: shield bounds interruption, not duration — shielded work awaiting a dead peer runs forever. The pair is the discipline:

async def flush_on_shutdown(exporter):
    flush = asyncio.ensure_future(exporter.flush())   # named: no orphan
    try:
        await asyncio.shield(flush)                   # outer cancel stops HERE
    except asyncio.CancelledError:
        async with asyncio.timeout(5):                # bound the protected work
            await flush                               # let it finish - or not
        raise                                         # then honor the cancel

Shielding everything is the abuse pattern: a service whose handlers all shield their bodies is simply unkillable — cancellation, timeouts, and graceful shutdown all degrade to SIGKILL. Shield is for the narrow class of must-complete work, always with a bound, always with a kept reference.

Why this works

Why did 3.11 add cancel counting at all? Because before it, nested cancellation sources could not tell their cancellations apart. wait_for-era bugs were real: a task cancelled by a timeout at the same moment its result arrived could lose the result, or convert an external cancellation into a TimeoutError — making a deliberate shutdown look like a slow upstream. The cancelling()/uncancel() counter gives every scope a claim check: convert only what you yourself requested. It is also why lesson 2 banned swallowing CancelledError — eat one, and some scope above you uncancels a request that was never delivered, corrupting the count for everyone outer.

Quiz

A request handler wraps a DB write in asyncio.shield() with no kept reference. The client disconnects and the framework cancels the handler. What happens to the write?

Recall before you leave
  1. 01
    Explain the full mechanism of asyncio.timeout, including how the boundary decides between TimeoutError and re-raising CancelledError, and why this fixes what wait_for got wrong.
  2. 02
    Give the shield discipline: what it actually guarantees, the two failure modes of using it alone, and the deadline pattern for multi-hop request budgets.
Recap

asyncio.timeout is lesson 2’s cancellation wearing a budget: a call_at timer cancels the scope’s own body, the body unwinds through ordinary CancelledError (cleanup rules included), and the boundary converts to TimeoutError only after uncancel() confirms the cancellation was its own — cancel-count bookkeeping that makes nested scopes compose deterministically and lets external cancellation pass through unclaimed, the precise machinery wait_for lacked when it raced results against cancellations (3.12 rebuilt it on the scope). Production budgets are deadlines, not constants: a client-facing 2 s shared across sequential hops means carrying one absolute deadline and deriving every inner scope from the remainder (timeout_at), with hierarchy kept strict — per-attempt under per-request under client-facing — because the inverted version quietly deletes your retry loop while metrics report zero retries. shield is the other half-tool: it splits the waiter from the work, so outer cancellation kills your await immediately while the inner task runs on detached — legitimate for commit-in-flight and write-behind flushes, but only with the reference kept (else the orphan’s result and failures vanish) and only paired with a bound, because shield limits who may interrupt and says nothing about how long — the shielded flush to a dead collector that hung a fleet’s drain is what the pair exists to prevent. Shield everything and you have built an unkillable service; bound everything and cancellation stays what it must be: prompt, truthful, and structured. Now when you reach for asyncio.shield, add the timeout and keep the reference in the same commit — the two missing pieces are exactly what turned a graceful shutdown into a fleet-wide drain delay.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.