Why does asyncio.CancelledError inherit from BaseException rather than Exception?
answer
- A signal, not a failure
- Which base class, and why that one
- The broad handler must not catch it
- Clean up in finally, then re-raise
- Sibling of KeyboardInterrupt and SystemExit
basics
~10 sCancellation 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.
solid answer
~50 s`Task.cancel()` does not stop anything; it records a request and arranges for `asyncio.CancelledError` to be raised inside the coroutine at its next await point. The task ends up in the cancelled state **only if that exception propagates all the way out**. Placing it under `BaseException` rather than `Exception` - which happened in Python 3.8 - means the broad `except Exception` handler that wraps most real worker loops cannot absorb it by accident. If a coroutine does catch it and returns normally, the task finishes successfully, `Task.cancelled()` is `False`, and whoever asked for the cancellation is lied to: a shutdown routine thinks the worker drained, a timeout scope has no cancellation to convert. The rules follow directly - put cleanup in `finally` so you never touch the exception, and if you must catch it, re-raise with a bare `raise`. Bare `except:` and `contextlib.suppress(asyncio.CancelledError)` still swallow it.
code
python · 19 linesimport asyncio
async def worker():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("cleaning up")
raise
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(0)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled:", task.cancelled())
asyncio.run(main())go deeper
Recall that cancellation shows up as asyncio.CancelledError raised inside your coroutine, that it is not an ordinary error, and that you should not catch it just to keep going. Cleanup belongs in a finally block.
Explain the mechanics: cancel() is a request, the exception lands at the next await, the task counts as cancelled only if it propagates out, and BaseException placement (Python 3.8) keeps except Exception from absorbing it.
Demonstrate you have debugged a process that would not exit. Name the swallow patterns - bare except, suppress(), return in finally - and explain how must-complete cleanup gets shielded or given its own deadline.
Own the convention: where cancellation is allowed to be observed at all, what a cancelled unit of work means for at-least-once side effects and idempotence, and how shutdown grace periods are set so cleanup is bounded rather than unbounded.
### The signal, not an error `asyncio.CancelledError` inherits directly from `BaseException`, not from `Exception`. That single placement in the hierarchy encodes a design decision: cancellation is a *control-flow signal* addressed to a specific task, of the same family as `KeyboardInterrupt` and `SystemExit`, and not a failure the task is entitled to handle and move on from. Because `except Exception` does not match `BaseException` subclasses, the broad handler that every real codebase wraps around its work — the one that logs the traceback and continues the loop — cannot swallow a cancellation by accident. It moved there in Python 3.8; before that it derived from `Exception` and exactly that accident was routine. ### What `Task.cancel()` actually does `Task.cancel()` does not stop anything. It records a cancellation request and arranges for `CancelledError` to be raised inside the coroutine at its next suspension point — the next `await` that yields to the event loop. If the coroutine is running synchronous code, delivery waits. If the coroutine is suspended on `asyncio.sleep` or a socket read, delivery is immediate at that point. `cancel()` accepts an optional message (added in 3.9) that rides along on the exception, which is useful for saying *why* in a shutdown path. From there it is an ordinary exception propagating up: `finally` blocks run, `async with` exit handlers run, context managers unwind. The task ends in the cancelled state — `Task.cancelled()` returns `True` — only if the `CancelledError` propagates all the way out of the coroutine. ### Why swallowing it breaks things Suppose a worker coroutine writes `try: ... except CancelledError: log(); return None`. The cancellation is now absorbed. The task finishes *successfully* with `None`; `Task.cancelled()` is `False`. Everything that asked for the cancellation is misled: - A shutdown routine that cancelled every pending task and is now gathering them may believe the worker shut down cleanly when it actually skipped its drain. - A timeout scope that cancelled the task to enforce its deadline sees no `CancelledError`, so it has no cancellation to convert and reports no timeout. - Worse, a coroutine that catches `CancelledError` and then *keeps awaiting* — retrying, sleeping, looping — cannot be stopped at all, and the process hangs on exit while something tries to await it. That is why the rule is stated so absolutely: **catch it only to clean up, then re-raise.** The idiomatic shape is not to catch it at all — put cleanup in `finally`, which runs during unwinding without touching the exception. ```python async def worker(inbox): try: while True: item = await inbox.get() await handle(item) finally: await flush() # runs on cancellation, exception keeps propagating ``` When you genuinely must observe the cancellation — to record a metric, or to distinguish a cancelled run from a failed one — catch and re-raise with a bare `raise`: ```python except asyncio.CancelledError: record_cancelled() # a metric, a log line raise ``` ### The handlers that still bite `BaseException` placement protects `except Exception`, and nothing else. These still swallow cancellation and are the ones to look for in review: a bare `except:`, an explicit `except BaseException:`, `contextlib.suppress(asyncio.CancelledError)`, and a `return` inside a `finally` block, which discards the in-flight exception entirely. Python 3.14 added a `SyntaxWarning` (PEP 765) for `return`/`break`/`continue` in a `finally`, precisely because that construct silently eats exceptions. There is one more subtlety. Cleanup that *awaits* can itself be cancelled: if the task is cancelled a second time while the `finally` block is suspended, a fresh `CancelledError` interrupts the cleanup mid-way. Cleanup that must complete goes inside `asyncio.shield` or under its own short `asyncio.timeout` block, and shutdown code should allow a grace period rather than cancelling twice immediately. ### Interview-ready summary `CancelledError` is a `BaseException` because it is a request that must reach the top of the coroutine for the request to be honoured. `except Exception` is safe by construction; bare `except:` and `suppress()` are not. Clean up in `finally`, re-raise if you catch, and treat any handler that returns normally after a cancellation as a bug that will show up as a process that will not exit.
- Which handlers still swallow a cancellation despite the BaseException placement?A bare `except:`, an explicit `except BaseException:`, `contextlib.suppress(asyncio.CancelledError)`, and a `return` inside a `finally` block, which discards whatever exception was propagating - Python 3.14 emits a SyntaxWarning for that last one. Any of these turns a cancelled task into a successfully finished one, and they are the specific patterns to hunt for in a service that will not shut down.
- Your cleanup in finally itself awaits something. What can go wrong?It can be cancelled in turn. A second cancel request while the finally block is suspended raises a fresh CancelledError inside the cleanup and leaves it half-done. Wrap cleanup that must complete in `asyncio.shield`, or give it its own short `asyncio.timeout` so a hung cleanup cannot block shutdown, and have shutdown code allow a grace period rather than cancelling twice straight away.
- How do you tell from the outside whether a task actually honoured its cancellation?`Task.cancelled()` returns True only if CancelledError propagated out of the coroutine. If the task finished with a result or another exception after being cancelled, it swallowed the request. Since 3.11 `Task.cancelling()` also reports the count of outstanding cancel requests, so a live task with a non-zero count is one that was asked to stop and has not.
It is a fire alarm rather than a customer complaint: staff may pause to lock the till on the way out, but nobody is allowed to hear it, note it down, and carry on serving.
saying these in an interview costs you the question
- Thinks except Exception catches asyncio.CancelledError
- Catches CancelledError, logs it, and returns a value
- Uses a bare except around awaits in a worker loop
- Believes Task.cancel() stops the coroutine immediately
- Puts cleanup after the await instead of in finally
- Treats a cancelled task as an error to be retried automatically