Skip to content
← All articles

Cancellation: CancelledError Is a Protocol, Not an Error

cancel() throws an exception into a coroutine and lets it decide. Why it inherits BaseException, what cancelling()/uncancel() are really for, and why shield() is usually a bug.

Cancellation correctness is the difference between a service that drains in two seconds on SIGTERM and one that gets SIGKILLed after thirty and loses whatever was in flight. It is also the part of asyncio most likely to be written by copying a snippet.

CancelledError is a protocol

Task.cancel() does not stop anything. The docs are precise: it “arranges for a CancelledError to be thrown into the wrapped coroutine on the next cycle of the event loop”. It returns False if the task is already done. The coroutine then “has a chance to clean up or even deny the request”.

So cancellation is a conversation. The canceller says “please stop”; the coroutine gets an exception at its next suspension point; what happens next is the coroutine’s decision. Three consequences:

  • Code with no await in it cannot be cancelled. A tight CPU loop ignores cancel() completely, because there is no suspension point to throw into. This is the same non-interruptibility that makes threads uncancellable, arriving through a different door.
  • finally blocks run. That is the entire value proposition: unlike killing a thread, cancelling a task unwinds the stack properly, so locks are released and context managers exit.
  • You can decline. The docs also say “suppressing cancellation completely is not common and is actively discouraged” — because the canceller usually has a deadline, and a task that declines is a task that gets abandoned or killed harder.

Why it inherits BaseException

In Python 3.8, CancelledError moved from Exception to BaseException. That one change is the difference between cancellation working and cancellation being a coin flip, because of this:

try:
    await do_work()
except Exception:
    log.exception("work failed")
    return DEFAULT

Before 3.8 that swallowed cancellation. The task would be told to stop, catch the CancelledError as an ordinary error, log it as a failure, return a default, and carry on — and the canceller would wait for a task that had decided not to stop. After 3.8, except Exception does not match, and the cancellation continues to propagate.

The rule that falls out: except BaseException in async code must re-raise, or you have to handle CancelledError explicitly. Every except BaseException that does not end in raise is a cancellation bug.

💡Your finally needs to await — release a lease, flush a buffer. Does that work while the task is being cancelled? click to reveal

Yes, and this is the detail that makes bounded cleanup possible at all.

asyncio’s cancellation is edge-triggered: cancel() delivers exactly one CancelledError. Once it has been delivered, the task’s _must_cancel flag is clear, so a subsequent await in a finally or except block suspends and resumes normally rather than immediately re-raising.

(trio and anyio are level-triggered by contrast: a cancelled scope raises at every checkpoint inside it, so an await in a finally needs an explicit shielded scope. Both designs are defensible — trio’s makes it impossible to accidentally escape cancellation, asyncio’s makes cleanup natural — but they are opposite, which is why mixing the two paradigms goes badly.)

The catch is that “cleanup can await” also means “cleanup can hang forever”, and now you are ignoring the cancellation you accepted. That is why cleanup needs its own bound: async with asyncio.timeout(...) around it, so a slow cleanup costs you a fixed amount and then gives up.

Note also that a second cancel() will interrupt cleanup, which is exactly how a shutdown sequence escalates.

cancelling() and uncancel()

Python 3.11 added a counter to Task. cancel() increments it; uncancel() decrements it. Neither is for application code — they exist so that constructs which cancel their own block can tell “I did this” from “someone cancelled me from outside”.

asyncio.timeout is the canonical user. On expiry it calls self._task.cancel() and, in __aexit__, checks whether uncancel() brings the count back to what it was on entry. If so, the cancellation was its own, and it converts the CancelledError into TimeoutError. If the count is still higher, an outer cancellation is also in flight, and it lets the CancelledError through untouched.

This is what makes timeouts nest correctly, and what lets a bounded cleanup run inside a task that has already been cancelled from outside. You will rarely call these methods; you rely on them constantly.

shield() is usually a bug

asyncio.shield(coro) protects an awaitable from the cancellation of its awaiter. The documentation’s own example is captioned “completely ignore cancellation (not recommended)”.

Three reasons to be suspicious of it:

  1. It does not stop the shielded work, it only detaches you from it. The awaiter gets CancelledError; the shielded task keeps running with nobody waiting for it. That is exactly the gather leak.
  2. It carries the weak-reference hazard. shield wraps its argument in a task internally, so the thing you shielded can be garbage-collected if nothing else holds it.
  3. The real requirement is almost always “bounded”, not “unbounded”. You want the flush to get a chance, not a licence. A finally with asyncio.timeout gives you the chance and keeps the deadline.