Cancellation is a request, delivered at an await point
When wait_for expires it calls task.cancel(), which arranges for CancelledError to be thrown into the coroutine the next time it suspends. If the coroutine is sitting in await asyncio.sleep(30) it receives the error almost immediately. If it is halfway through a tight synchronous loop, a json.loads of a large payload, or a blocking driver call, there is no suspension point to deliver the exception to, and the loop is blocked anyway — nothing else runs, cancellation included, until that code returns.
This is the first thing to say in an interview, because it reframes the question. The bug is not that the timeout failed to fire. It is that the code being timed out never agreed to stop.
The four ways a task survives its own cancellation
It swallows the exception. This is the most common cause and it hides in ordinary-looking code:
async def sync_orders(session):
try:
for order in await fetch_pending(session):
await push_to_warehouse(order)
except Exception:
log.exception("sync failed, continuing")
await mark_batch_complete(session)
Since Python 3.8 CancelledError inherits from BaseException rather than Exception, so except Exception no longer catches it — that change exists precisely because this bug was so common. But a bare except: or an explicit except BaseException: still traps it, and plenty of retry decorators and middleware are written that way.
It is shielded. asyncio.shield(coro) deliberately protects the inner task from cancellation propagating inward. The caller's await is cancelled, the inner work is not. Used knowingly this is correct — you want the payment to finish even though the HTTP client hung up. Used by copy-paste, it produces exactly the symptom in the question.
It is running in a thread. loop.run_in_executor(None, blocking_call) returns a future you can cancel, but cancelling it only detaches the future from the loop. There is no mechanism to interrupt a running thread in CPython, so the blocking call runs to completion and whatever it mutates gets mutated. The awaiting coroutine returns promptly and the work does not stop.
Nobody is holding a reference to it. asyncio.create_task returns a task the event loop only weakly references. If you discard the return value, the task can be garbage collected mid-flight, which produces the Task was destroyed but it is pending! warning — and in the meantime it is running outside any structure that would cancel it.
Why the caller sometimes hangs instead
There is a related symptom worth knowing because it looks like the opposite problem. wait_for does not just cancel and return; it awaits the cancellation, so it does not raise TimeoutError to your code until the inner task has actually finished unwinding. A task that catches CancelledError, runs a slow cleanup, and never re-raises will make wait_for block well past its own timeout. If someone reports that "the five second timeout took thirty seconds", this is usually why.
Fixing it
Re-raise cancellation after cleanup. If a task legitimately needs to catch CancelledError to close a file or release a lock, it must re-raise so the cancellation completes:
async def sync_orders(session):
try:
...
except asyncio.CancelledError:
await release_lease(session)
raise
except Exception:
log.exception("sync failed")
raise
Swallowing CancelledError without re-raising leaves the task in a state where the runtime believes it was cancelled and the code believes it was not.
Use structured concurrency. On Python 3.11 and later, asyncio.timeout and TaskGroup replace the hand-rolled pattern and make the cancellation scope explicit:
async with asyncio.timeout(5):
async with asyncio.TaskGroup() as tg:
tg.create_task(sync_orders(session))
tg.create_task(refresh_stock(session))
The TaskGroup keeps strong references, cancels siblings when one fails, and does not exit until every child has actually stopped. That removes the discarded-reference failure and the "caller moved on while work continued" failure in one move.
Keep blocking work off the loop, and make it interruptible where you can. A synchronous driver call cannot be cancelled, so the honest fix is a driver-level timeout — statement_timeout in Postgres, a socket timeout on the HTTP client — enforced by the thing actually doing the blocking rather than by asyncio.
Make the side effect safe. This is the part that survives every other mistake. If the task writes to shared state, guard the write so that a late-arriving completion cannot corrupt anything: check a cancellation flag or generation counter immediately before committing, or make the write idempotent and keyed so a stale one is discarded. Faster cancellation reduces the window; a guarded write closes it.
Proving it in a test
A test that asserts wait_for raised TimeoutError proves only that the caller stopped waiting, which is the thing that already worked. The assertion that matters is about the task: capture the task object, await a short settle period, and assert task.cancelled() is true, or assert that the side effect the task would have produced did not appear. Being able to articulate that distinction — the caller returning is not the work stopping — is usually the strongest signal in the whole answer.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).