Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

CancelledError while awaiting a task you cancelled? Don’t swallow the controller’s shutdown

A child can finish by cancellation at the same moment its controller is cancelled from outside. Record task ownership, let each child clean up in finally, collect child outcomes without a blanket except-pass, and prove that outer cancellation still propagates.

Asyncio cancellation-origin map
Original illustration: distinguish child completion from controller cancellation. Watch at Ease

A child can finish by cancellation at the same moment its controller is cancelled from outside. Record task ownership, let each child clean up in finally, collect child outcomes without a blanket except-pass, and prove that outer cancellation still propagates.

CancelledError inherits BaseException and is normally re-raised after cleanup. Task.cancel requests cancellation; it does not guarantee the coroutine will accept it.

1. Name every task owner and shutdown trigger

List the controller, every child it owns, who may cancel each one, and whether sibling failure should cancel the rest. Save task names and the shutdown trigger; do not start with except CancelledError: pass.

A CancelledError observed while awaiting a child may represent the child finishing its own cancellation or the controller receiving an unrelated cancel request. The exception type alone does not prove origin.

2. Put child cleanup in finally

Release child-owned resources in try/finally and normally let CancelledError propagate after cleanup. Cancellation is delivered at an await point, and another await inside unprotected cleanup can itself be cancelled.

Keep synchronous cleanup short. If asynchronous finalization must finish, give it an explicit small boundary and document whether shielding is allowed; do not turn shielding into an unbounded shutdown hang.

3. Choose TaskGroup or gather semantics

Use TaskGroup when the scope owns all children and sibling failure should cancel the group; leaving the context waits for them. Do not swallow cancellation inside children because structured concurrency depends on it.

For independently cancelled child outcomes, gather(return_exceptions=True) can return a child cancellation as a result. Official docs separately guarantee that cancelling gather itself still propagates, regardless of return_exceptions.

4. Preserve controller cancellation

Avoid a blanket catch around await child. If manual state logic is unavoidable, record child.done(), child.cancelled() and current_task().cancelling(), and re-raise whenever the controller has a pending cancellation.

Do not call uncancel merely to silence state. Python describes it as an internal structured-concurrency tool; suppressing cancellation is exceptional and must remove the state deliberately, not accidentally.

5. Test simultaneous cancellation and exit

Test three cases: only the child is cancelled, only the controller is cancelled while awaiting, and both happen in the same loop turn. Assert resource closure, expected task states and whether cancellation reaches the controller caller.

At process exit, inspect owned tasks only; unrelated loop tasks are not yours to cancel. Verify no owned task is pending, no exception was left unretrieved and a timeout path cannot convert external shutdown into success.

Asyncio shutdown primitive map
Original illustration: own tasks, collect outcomes and preserve outer cancellation. Watch at Ease