skip to content

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

level: seniorimportance: nice to knowfreq 18%

answer

  1. Nested scopes must not steal each other's cancels
  2. The count is not a boolean
  3. Whose cancellation was this, exactly
  4. Increment on cancel, decrement on uncancel
  5. cancelling() is not cancelled()

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.

solid answer

~50 s

Since Python 3.11 an `asyncio.Task` counts outstanding cancellation requests: `Task.cancel()` increments the count, `Task.uncancel()` decrements it, and `Task.cancelling()` reports it - which is a different question from `Task.cancelled()`, meaning the task already finished cancelled. `async with asyncio.timeout(1):` is implemented by cancelling the *current* task, since that is the only way to interrupt an arbitrary block of awaits. On entry it records the current count; on exit it calls `uncancel()` and compares. If the count is back where it started and a `CancelledError` is passing through, the cancellation was its own, so it raises `TimeoutError from` it. If the count is still elevated, someone else also cancelled the task and the `CancelledError` is passed through untouched. `asyncio.TaskGroup` uses the same counter, and since 3.12 `asyncio.wait_for` inherits it. This is machinery for cancellation scopes, not for application code.

code

python · 12 lines
python
import asyncio

async def main():
    task = asyncio.current_task()
    print("before:", task.cancelling())
    try:
        async with asyncio.timeout(0.1):
            await asyncio.sleep(5)
    except TimeoutError:
        print("after:", task.cancelling())

asyncio.run(main())

go deeper

for a junior

Not expected at this level. It is enough to know that asyncio.timeout() gives you TimeoutError rather than CancelledError, and that some bookkeeping inside asyncio makes that translation possible.

for a middle

Be able to say that a timeout works by cancelling the current task and converting the exception, and that asyncio tracks cancellation requests as a count so nested scopes can tell their own request apart from an external one.

for a senior

Explain the entry/exit comparison and why it matters: a scope that failed to check would swallow an outer cancellation and hang a shutdown. Reading a non-zero cancelling() as evidence that a coroutine is swallowing its cancel is the diagnostic payoff.

for a principal

Own the boundary between framework and application code. Cancellation-scope machinery belongs in libraries and shared shutdown utilities; business logic calling uncancel() should be treated as a defect, and custom scopes need the same counted discipline as the stdlib's.

### Cancellation is counted, not boolean Since Python 3.11 an `asyncio.Task` carries a counter of outstanding cancellation requests. `Task.cancel()` increments it, `Task.uncancel()` decrements it, and `Task.cancelling()` returns the current count. This is a different question from `Task.cancelled()`, which asks whether the task has already *finished* in the cancelled state. The counter exists to make cancellation *scopes* composable: without it, a nested construct that cancels a task in order to break out of a block cannot tell its own cancellation apart from one that arrived from outside. ### Why `asyncio.timeout()` needs it `async with asyncio.timeout(1):` is implemented by cancelling the *current* task when the deadline fires. That is the only way to interrupt an arbitrary block of awaits. But the caller of the block must see `TimeoutError`, not `CancelledError` — and if an outer cancellation happened to arrive at the same moment, the scope must not eat it. So the context manager does the bookkeeping. On entry it records `Task.cancelling()` — the number of cancellations already outstanding. When the deadline fires it calls `cancel()` on the task, marking itself as expiring. On exit it calls `Task.uncancel()`, withdrawing its own request, and compares the resulting count against what it recorded on entry. If the count is back where it started and the exception passing through is a `CancelledError`, the cancellation was its own and it raises `TimeoutError from` that exception. If the count is still elevated, somebody else also cancelled this task, and the `CancelledError` is allowed to keep propagating untouched. Since Python 3.12 `asyncio.wait_for` is built on the same context manager, so it inherits the identical accounting. `asyncio.TaskGroup` uses the same counter to distinguish the cancellation it issued to its own body from an external one. The counter is the mechanism that lets scopes nest at all. ### What `uncancel()` does not do It does not undo a delivered exception. Once `CancelledError` has been raised inside the coroutine, the exception exists and is propagating; `uncancel()` only adjusts the request count so that enclosing machinery can reason about *who asked*. Nor does it revive a task that already finished cancelled. Calling it does not resume anything. It is also not a general-purpose "ignore this cancellation" button. Application code calling `uncancel()` to keep working after a shutdown routine cancelled it is doing the counted equivalent of swallowing `CancelledError`: the caller believes the task stopped and it has not. The documented audience for `uncancel()` is code that *implements a cancellation scope* — a timeout, a task group, a supervisor — not ordinary business logic. ### Reading a stuck program through the counter The counter is a useful diagnostic. A task whose `cancelling()` is non-zero has been asked to stop and has not stopped — either it is blocked between suspension points, or something in its stack caught the `CancelledError` and did not re-raise. That is the precise reading of the "swallowing cancellation breaks shutdown" rule: swallowing does not decrement the counter, so the request stays outstanding while the task carries on, and every enclosing scope waits for a stop that never happens. ```python import asyncio async def main(): task = asyncio.current_task() print("before:", task.cancelling()) # 0 try: async with asyncio.timeout(0.1): await asyncio.sleep(5) except TimeoutError: print("after:", task.cancelling()) # 0 - the scope withdrew its own request asyncio.run(main()) ``` ### Interview-ready summary `Task.cancelling()` is a count of outstanding cancel requests, not a flag. `asyncio.timeout()` cancels the current task to escape its block and then calls `Task.uncancel()` so it can prove the cancellation was its own before rewriting it as `TimeoutError`; if the count says an outer cancellation is also in flight, the `CancelledError` is passed through instead. Both methods arrived in 3.11, they are intended for cancellation-scope implementations rather than application code, and a persistently non-zero `cancelling()` is a strong signal that some coroutine is swallowing its cancellation.

  • What is the difference between Task.cancelling() and Task.cancelled()?
    `cancelling()` returns a count of cancellation requests currently outstanding against a task that may still be running. `cancelled()` is a terminal-state question: it is True only once the task has finished and CancelledError propagated out of it. A live task with a non-zero `cancelling()` count is one that was asked to stop and has not - usually because it is stuck between await points or something swallowed the exception.
  • Should application code ever call Task.uncancel()?
    Almost never. Its documented audience is code implementing a cancellation scope - a timeout, a task group, a supervisor - that cancelled a task deliberately and needs to withdraw its own request. Calling it in business logic to keep working after a shutdown cancelled you is the counted equivalent of swallowing CancelledError: the caller believes you stopped and you have not.
  • Does uncancel() undo a CancelledError that has already been raised?
    No. Once the exception is raised inside the coroutine it is propagating like any other exception; uncancel() only adjusts the request count so enclosing machinery can work out who asked. It does not resume anything and it does not revive a task that already finished in the cancelled state.

It is a signed-out key: whoever took it must hand it back, and the front desk counts keys rather than asking whether the door is locked, so two people borrowing at once never confuse each other.

saying these in an interview costs you the question

  • Believes uncancel() revives an already-cancelled task
  • Thinks Task.cancelling() returns a boolean, not a count
  • Confuses Task.cancelling() with Task.cancelled()
  • Calls uncancel() in business logic to ignore a shutdown
  • Assumes a timeout scope may swallow an outer cancellation

context