skip to content

Cancellation and Timeouts

Cancellation arrives as CancelledError raised at the next await, so cleanup, shielding and never swallowing it are the rules. Timeouts ride the same machinery; interviewers ask about in-flight work.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

When asyncio.wait_for() times out, what happens to the wrapped coroutine?

level: juniorimportance: must knowfreq 65%

answer

  1. A deadline, not a background killer
  2. It does two things, not one
  3. Cancel first, rename at the boundary
  4. CancelledError inside, TimeoutError outside
  5. Only lands at an await point

basics

~10 s

asyncio.wait_for cancels the wrapped awaitable when the deadline passes, waits for that cancellation to finish unwinding, then raises TimeoutError to the caller. The inner work is stopped, not left running in the background.

solid answer

~50 s

`asyncio.wait_for(aw, timeout)` wraps its argument in a Task and arms a deadline. When the deadline fires it requests cancellation of that inner task, so `asyncio.CancelledError` is raised inside it at its next await point; `wait_for` then **awaits** the task so its `finally` blocks run, and only afterwards raises `TimeoutError` to the caller. A timeout is therefore cancellation plus a renamed exception at the boundary. Two consequences matter in practice. Cancellation is cooperative, so a coroutine doing synchronous work between awaits will overrun its deadline - the timer fires on time, delivery waits for the next suspension point. And if the inner coroutine catches `CancelledError` and returns a value, `wait_for` returns that value and raises nothing. Since 3.11 the exception raised is the builtin `TimeoutError` (`asyncio.TimeoutError` is an alias); since 3.12 `wait_for` is implemented on top of `asyncio.timeout()`.

code

python · 12 lines
python
import asyncio

async def slow():
    await asyncio.sleep(5)

async def main():
    try:
        await asyncio.wait_for(slow(), timeout=0.1)
    except TimeoutError:
        print("timed out")

asyncio.run(main())

go deeper

for a junior

Be ready to say plainly that a timeout cancels the work and then raises TimeoutError, and that the wrapped coroutine does not carry on in the background. Knowing which exception the caller catches is the screening part.

for a middle

Explain the mechanics: the awaitable is wrapped in a Task, cancellation is delivered at the next await, the task is awaited so cleanup runs, and TimeoutError is raised only afterwards. Mention the 3.11 alias to the builtin TimeoutError.

for a senior

Show that you have debugged an overrun deadline: blocking work between awaits defeats the timer, and a coroutine that swallows CancelledError turns a timeout into a silent success. Say where blocking work belongs instead.

for a principal

Own the policy angle - where deadlines are set, whether budgets are absolute and inherited across calls via asyncio.timeout_at() rather than restarted at every layer, and what a timeout means for retries and duplicate side effects downstream.

### What `wait_for` actually is `asyncio.wait_for(aw, timeout)` is a coroutine that does three things in order. It makes sure the awaitable you handed it is a Task (wrapping a coroutine object with `ensure_future` if it is not one already), it arms a deadline on the running event loop, and it awaits that task. If the task finishes first, the timer is disarmed and the result is returned. If the deadline arrives first, `wait_for` requests cancellation of the inner task, **awaits it until it has finished unwinding**, and only then raises `TimeoutError` to its caller. The load-bearing word is *cancel*. Python has no mechanism to stop a coroutine at an arbitrary instruction. A timeout is therefore not a kill; it is a cancellation request plus a translation of the resulting `CancelledError` into `TimeoutError` at the boundary. ### Cancellation is delivered at await points When the inner task is cancelled, the event loop arranges for `asyncio.CancelledError` to be raised inside it *at its next suspension point* — the next `await` that actually yields to the loop. Between suspension points a coroutine runs to completion like any other Python function, so a coroutine that does a second of synchronous CPU work, or calls a blocking socket API directly, will simply overrun its deadline. The timer fires on schedule; delivery waits for the coroutine to come back to the loop. The fix is structural: push blocking work off the loop with `asyncio.to_thread` or the event loop's `run_in_executor`, so the awaiting side stays interruptible. ### Cleanup runs, and it is awaited Because `CancelledError` is a normal exception once raised, `finally` blocks and `async with` exit handlers run during unwinding. And because `wait_for` awaits the cancelled task rather than abandoning it, that cleanup gets scheduled and completed before `TimeoutError` reaches you. This is a real guarantee worth stating in an interview: after `wait_for` raises, the wrapped operation is finished, not racing you in the background. The one caveat is that a `finally` block that itself awaits can be cancelled again if someone cancels the outer task while the cleanup is suspended; critical cleanup that must complete belongs inside `asyncio.shield`. ### Suppressing the cancel changes the outcome If the wrapped coroutine catches `CancelledError` and returns a value instead of re-raising, there is nothing left for `wait_for` to report: it returns that value, and no `TimeoutError` is raised at all. This is documented behaviour, and it is the concrete reason the "never swallow cancellation" rule is not merely stylistic — swallowing it silently converts a timeout into a success. ### The modern form: `asyncio.timeout()` Python 3.11 added the `asyncio.timeout()` and `asyncio.timeout_at()` asynchronous context managers. They apply one deadline to a whole *block* rather than to a single awaitable, which is usually what you want: three sequential awaits under one budget instead of three independent budgets that can sum to triple the intended wait. `asyncio.timeout_at()` takes an absolute loop-clock deadline, useful when a budget is inherited from a caller, and the object the context manager yields exposes `asyncio.Timeout.when()` and `asyncio.Timeout.reschedule()` for extending a deadline in flight. In Python 3.12 `wait_for` was reimplemented on top of `asyncio.timeout()`, so the two share one code path; on 3.14 the practical rule is `wait_for` for exactly one awaitable, `async with asyncio.timeout(...)` for a region. ### Which exception, exactly Before 3.11, `asyncio.TimeoutError` was its own class. Since 3.11 it is an alias of the builtin `TimeoutError` (which itself subclasses `OSError`), so `except TimeoutError` and `except asyncio.TimeoutError` are the same handler on 3.14, and a timeout from a socket and a timeout from `wait_for` are caught by the same clause. Note the asymmetry that trips people up: the awaiting side sees `TimeoutError`, while inside the wrapped coroutine the exception that arrives is `asyncio.CancelledError`. Code that expects `TimeoutError` inside the timed-out coroutine will never catch anything. ### Interview-ready summary A timeout in asyncio is cancellation with a rename at the boundary. It is cooperative, so it only lands at an `await`; it waits for cleanup, so the operation is genuinely over when you see `TimeoutError`; and it can be defeated from the inside by a coroutine that swallows `CancelledError`.

  • When would you use asyncio.timeout() instead of asyncio.wait_for()?
    Whenever the deadline covers more than one awaitable. `async with asyncio.timeout(5):` applies one budget to a whole block, so three sequential awaits share five seconds instead of getting five each. `asyncio.timeout_at()` takes an absolute loop-clock deadline, which is what you want when a budget is inherited from a caller. `wait_for` stays convenient for exactly one awaitable.
  • The timeout is one second but the call takes ten. How is that possible?
    Cancellation is delivered at await points only. If the coroutine spends ten seconds in synchronous CPU work or a blocking library call, it never returns to the event loop, so the CancelledError cannot be raised and the whole loop is stalled meanwhile. Move blocking work off the loop with `asyncio.to_thread` or the event loop's `run_in_executor` so the awaiting side stays interruptible.
  • Does cleanup inside the timed-out coroutine still run?
    Yes. CancelledError is a normal exception once raised, so `finally` blocks and async context-manager exits run during unwinding, and `wait_for` awaits the cancelled task before raising, so that cleanup completes before you see TimeoutError. The exception is a `finally` that itself awaits - it can be interrupted by a second cancellation, which is why must-complete cleanup goes inside `asyncio.shield`.

It is a referee blowing the whistle, not a trapdoor: play stops at the next natural break, the players walk off, and only then is the result recorded.

saying these in an interview costs you the question

  • Says the wrapped coroutine keeps running after the timeout
  • Thinks wait_for stops the coroutine instantly, mid-statement
  • Expects a timeout to interrupt blocking synchronous code
  • Believes finally blocks are skipped when a timeout fires
  • Expects TimeoutError to be raised inside the timed-out coroutine

context

open as a page

Why does asyncio.CancelledError inherit from BaseException rather than Exception?

level: middleimportance: must knowfreq 70%

basics

~10 s

Cancellation is a control-flow signal, not a failure, so since Python 3.8 asyncio.CancelledError derives from BaseException and a broad except Exception cannot swallow it. Catch it only to clean up, then re-raise.

open as a page

When is asyncio.shield the right tool, and what does it not protect?

level: seniorimportance: should knowfreq 40%

basics

~20 s

asyncio.shield keeps an inner operation running when the code awaiting it is cancelled: the awaiter still raises CancelledError, the shielded task carries on. It protects against a caller's cancellation only, not against direct cancellation or loop teardown.

open as a page

Why does asyncio.timeout() call Task.uncancel() when its deadline fires?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

The timeout escapes its block by cancelling the running task, then calls Task.uncancel() to withdraw its own request. The resulting Task.cancelling() count tells it whether the CancelledError was its own, so an outer cancellation is passed through instead.

open as a page