open atlas
↑ Back to track
Python for JS/TS developers PY · 11 · 02

Cancellation semantics: an injected CancelledError, the bare-except immortality bug, and cleanup that must re-raise

task.cancel() throws CancelledError at the next suspending await; between awaits code is uninterruptible. except Exception misses it, bare except swallows it — immortal tasks. Clean up in finally, bound cleanup awaits, re-raise. TaskGroup cancels siblings on first failure.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The deploy should have taken ninety seconds. The consumer service got SIGTERM, logged “shutting down, cancelling 32 workers” — and then nothing, for ten minutes, until the orchestrator lost patience and sent SIGKILL. The postmortem found the worker loop wrapped in a bare except: written years earlier under the comment “workers must survive anything”. It was surviving cancellation. Every task.cancel() dutifully threw CancelledError into the worker at its next await; the bare except caught it, logged worker recovered: CancelledError(), and went right back to queue.get(). Thirty-two immortal tasks, a gather() that could never return, and — the expensive part — thirty-two in-flight jobs that were never acked, redelivered by the broker after restart and processed twice. Customer emails went out in duplicate. The fix was three lines of exception discipline. The lesson was the model itself: cancellation in asyncio is not a kill switch, it is an exception your code is trusted to let through — and that trust is exactly where it breaks.

Cancellation is an injected exception, not a flag

task.cancel() does not stop anything. It arranges for the loop to throw CancelledError into the coroutine at its next suspension point — mechanically, the Task resumes its coroutine with coro.throw(CancelledError()) instead of send() (lesson 1’s machinery, weaponized). Two consequences define the whole model. First, code between awaits is uninterruptible: a guarantee — your dict update or two-step state change can never be torn in half by cancellation — and a trap — a 30-second pure-CPU stretch is uncancellable for its full duration, because there is no suspension point for the throw to land on. Second, cancellation is only deliverable at awaits that actually suspend: the lesson-1 distinction returns with teeth. An await on a completed future or a synchronously-chaining coroutine is not a cancellation point. For long CPU sections that must stay responsive to cancel, the honest workaround is a periodic await asyncio.sleep(0) — it inserts a real suspension where the throw can land, at the cost of a loop round-trip per check.

The swallowing bug, precisely

How many codebases have an immortal task lurking in shutdown? More than you’d think — and the mechanism is a single wrong word in an exception clause. Since Python 3.8, CancelledError inherits from BaseException, not Exception — a deliberate breaking change made so that the most common defensive pattern stays safe:

async def worker(q):
    while True:
        job = await q.get()
        try:
            await handle(job)
        except Exception:        # does NOT catch CancelledError (3.8+): good
            log.exception("job failed, continuing")
        # except:                # catches BaseException: cancellation dies here
        # except BaseException:  # same bug, spelled honestly

except Exception lets cancellation fly through — the task unwinds and ends in the cancelled state, exactly as the canceller intended. A bare except (or except BaseException) catches it, and if the handler then continues the loop, the task is immortal: cancel() returns True, the exception is delivered, and nothing dies. That was the Hook bug, and it predates 3.8 in spirit — before 3.8, even except Exception swallowed cancellation, which is why old codebases are minefields. The discipline when you do need to observe cancellation: catch it, clean up, re-raise:

        except asyncio.CancelledError:
            await q.nack(job)    # release the in-flight job (bound this! see below)
            raise                # ALWAYS - the canceller is owed the unwind

Swallowing without re-raising has a second cost beyond immortality: await task then returns normally, task.cancelled() is False, and every piece of framework machinery that checks “did my cancel work” — TaskGroup, timeout scopes — is now lied to.

Quiz

Code review: a worker wraps its job handling in `try: await handle(job)` / `except: log('retrying')` and continues its while-True loop. What happens when shutdown calls task.cancel()?

await in finally: the double-cancel trap

Cleanup that itself awaits is where correct-looking code hangs. A cancelled task runs its finally blocks as the exception unwinds — but an await inside finally is a new suspension point, and a second cancel() (an impatient shutdown loop, a timeout scope expiring around you) lands exactly there, abandoning the rest of the cleanup. Worse, the cleanup await itself can block forever — a nack() to a dead broker — and now the task is unkillable-by-politeness. The discipline: bound every await in a cancellation path. Wrap cleanup in asyncio.timeout(...) (next lesson dissects it), or shield genuinely-must-finish work; never let cleanup wait on the same conditions that caused the cancellation.

finally:
    with contextlib.suppress(TimeoutError):
        async with asyncio.timeout(2):   # cleanup gets 2 s, not forever
            await q.nack(job)

TaskGroup: structured cancellation, and whose cancel it was

You know TaskGroup behavior from the concurrency unit: first child failure cancels the siblings, results arrive as an ExceptionGroup. The deep detail is how it tells internal cancellation (the group cancelling its own children) from external (someone cancelled the whole group from outside): each task keeps a cancellation countcancel() increments it, uncancel() decrements. When a TaskGroup or timeout scope cancels the body for its own reasons, it later calls uncancel() to consume that request and decide: count back to zero means the cancel was mine, so report the sibling failure (or TimeoutError); still positive means an outer cancel is also in flight and must keep propagating. Honestly: uncancel() is framework machinery — application code almost never calls it — but knowing it exists explains why swallowing a CancelledError inside a TaskGroup or timeout scope corrupts their bookkeeping, and why the re-raise rule is not etiquette but a protocol.

Why this works

Why an injected exception instead of a kill switch? Because cancellation must compose with cleanup. A hard kill (the thread-killing fantasy) can land between any two instructions — mid-commit, holding a lock, half-written state. An exception thrown only at awaits guarantees your invariants hold at every cancellation point, rides the existing try/finally and async-with machinery for free, and makes cleanup expressible in the language you already know. The price is cooperation: code that catches without re-raising, or never awaits, opts out of being cancellable — which is why the discipline in this lesson exists.

Quiz

A task is 1 second into a 30-second pure-Python CPU section (no awaits inside). task.cancel() is called now. When does the task actually stop?

Recall before you leave
  1. 01
    Explain mechanically what task.cancel() does, where the exception can and cannot land, and the two-sided consequence of awaits being the only delivery points.
  2. 02
    State the exception-handling discipline for cancellation and what each violation costs: except Exception, bare except, catching without re-raise, unbounded awaits in finally.
Recap

asyncio cancellation is an injected exception riding the generator machinery from lesson 1: cancel() asks the loop to resume the target with throw(CancelledError) at its next genuinely-suspending await, which makes code between awaits atomic — a real guarantee your two-step state changes silently rely on — and makes long CPU sections uncancellable until they reach a landing point, with periodic await asyncio.sleep(0) as the honest, latency-priced workaround. The exception-handling rules follow from one fact: since 3.8 CancelledError is a BaseException, so except Exception stays safe while bare except and except BaseException swallow cancellation — the Hook incident, where thirty-two workers logged “recovered” from their own death sentence, shutdown hung ten minutes to SIGKILL, and unacked jobs double-processed after redelivery. Legitimate handling is observe, clean up, re-raise: anything less lies to await task, to task.cancelled(), and to the cancel-count protocol (cancelling/uncancel) by which TaskGroup distinguishes a sibling-failure cancellation it owns from an external one it must propagate — machinery you rarely call but constantly depend on. Cleanup itself is the last trap: an await inside finally is a fresh suspension point where a second cancel lands, and an unbounded cleanup await can hang on the same dead dependency that caused the cancellation — bound it with asyncio.timeout, which is exactly where the next lesson picks up. Now when you see a shutdown hang that only clears on SIGKILL — check bare excepts first; one of them is catching the exception meant to end the task.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.