Cancelling Tasks with a Message¶
Task.cancel(msg=...) attaches a reason to a cancellation: the CancelledError raised inside the task carries the message in args, and so does the error seen by whoever awaits the task. That turns "this task was cancelled" in a log into "this task was cancelled because of shutdown" or "because the client disconnected" — exactly what you need when debugging why work stopped. Tested on Python 3.11 through 3.14 with identical results: the message "shutdown: deploy 42" arrived inside the task and at the awaiter; a coroutine further up the same task saw it too; cancelling a gather with a message delivered it to every child. Some cancellations carry no message at all: timeouts from wait_for and asyncio.timeout, and sibling cancellations by a failing TaskGroup, all produced an empty args. And when a task was cancelled twice with different messages, the first message won. This guide uses messages to make cancellations explain themselves.
Prerequisites¶
- Python 3.11+; behaviour identical on 3.11, 3.12, 3.13 and 3.14 in testing.
- Cancellation basics, from cancelling a task and waiting for it to finish.
- Structured logging, from Observability & Tracing.
1. Pass a reason when you cancel¶
Any code that cancels a task knows why. Say so:
task.cancel(msg="shutdown: SIGTERM received")
# elsewhere
worker.cancel(msg=f"rebalance: partition {partition} revoked")
request_task.cancel(msg="client disconnected")
The message is a string by convention; any object works, but strings serialize cleanly into logs. Keep messages short and structured — a reason category and a detail — so they can be grouped in log queries. The message does not change cancellation semantics at all: the task is cancelled exactly as it would be without it.
Verify: each place in your codebase that calls .cancel() passes a message naming the reason.
2. Read the reason inside the task and at the awaiter¶
Inside the cancelled task, the reason is in the exception's args; the awaiting code receives a CancelledError with the same args:
async def worker() -> None:
try:
await consume_forever()
except asyncio.CancelledError as exc:
reason = exc.args[0] if exc.args else "unspecified"
log.info("worker stopping: %s", reason) # measured: "shutdown: deploy 42"
await asyncio.shield(flush_state())
raise
task = asyncio.create_task(worker())
...
task.cancel(msg="shutdown: deploy 42")
try:
await task
except asyncio.CancelledError as exc:
log.info("worker cancelled: %s", exc.args) # measured: ('shutdown: deploy 42',)
Tested: both sides saw ('shutdown: deploy 42',), and an outer coroutine that awaited an inner one within the same task saw the message as the error propagated up through it. Always re-raise after reading the reason; the message is information, not permission to swallow the cancellation, as covered in understanding Task.cancelling() and uncancel().
Verify: the shutdown log shows each worker's stop reason, matching the code path that cancelled it.
3. Know which cancellations carry no message¶
Some cancellations come from the standard library, which does not set a message. Tested on all four versions: a coroutine timed out by asyncio.wait_for or asyncio.timeout saw args == (), and so did a task cancelled by its TaskGroup because a sibling failed:
def describe(exc: asyncio.CancelledError) -> str:
if exc.args:
return str(exc.args[0]) # an explicit reason from our own code
task = asyncio.current_task()
if task is not None and task.cancelling() == 0:
return "cancelled and already uncancelled"
return "timeout, task group or untagged cancel" # no message available
The absence of a message is itself informative once your own code always sets one: an empty args then means the cancellation came from a timeout, a task group, or a library. For timeouts, the caller sees TimeoutError anyway, which is where that reason belongs, as discussed in telling TimeoutError apart from CancelledError.
Verify: log lines for cancellations with empty args correlate with timeouts or task-group failures in the same request.
4. Remember that the first message wins¶
A task can be asked to stop more than once before it gets the chance to run. Tested: cancel("first") followed by cancel("second") delivered ('first',) inside the task and at the awaiter:
task.cancel("client disconnected")
task.cancel("shutdown") # does not replace the first reason
# If a later reason must take priority, record it separately
shutdown_reason: dict[asyncio.Task, str] = {}
def cancel_for_shutdown(task: asyncio.Task) -> None:
shutdown_reason[task] = "shutdown"
task.cancel("shutdown")
The first request is the one that actually wakes the task, so its message is the one carried by the exception. When several parts of a system may cancel the same task — a request watchdog and a shutdown coordinator — and you need to know all of them, keep a side record keyed by task, or design ownership so only one component cancels each task.
Verify: a test that cancels twice asserts the first message, so a future change of behaviour is noticed.
5. Use messages in shutdown and supervision code¶
Messages pay off most where many tasks are cancelled at once and someone has to work out afterwards what happened:
async def shutdown(tasks: set[asyncio.Task], reason: str, grace: float = 10.0) -> None:
for task in tasks:
task.cancel(msg=f"shutdown: {reason}")
done, pending = await asyncio.wait(tasks, timeout=grace)
for task in done:
if task.cancelled():
continue # stopped as asked
if exc := task.exception():
log.error("task %s failed during shutdown", task.get_name(), exc_info=exc)
for task in pending:
log.warning("task %s ignored cancellation (%s)", task.get_name(), reason)
async def supervise(name: str, factory) -> None:
while True:
task = asyncio.create_task(factory(), name=name)
try:
await task
except asyncio.CancelledError as exc:
if task.cancelled() and not asyncio.current_task().cancelling():
log.warning("%s cancelled from elsewhere: %s; restarting", name, exc.args)
continue
raise
Naming tasks (create_task(..., name=...)) and giving reasons together make shutdown logs readable: "consumer-3 stopping: shutdown: SIGTERM" instead of a bare traceback. The supervisor distinguishes "my child was cancelled by someone else" (restart it) from "I am being cancelled" (stop), using the counter from the previous guide.
Verify: a rolling restart produces one stop line per task with the shutdown reason, and no anonymous CancelledError tracebacks.
Verification¶
Cancellation messages are used well when:
- Every
cancel()call in your code passes a reason. - Handlers read
exc.args, log it, and re-raise. - Empty
argsare understood as timeouts, task groups or library cancels. - Shutdown and supervision logs name each task and its stop reason.
Diagnostic Hook: count cancellations by reason (from exc.args) and by task name. An unexpected rise in "client disconnected" points at client-side timeouts or proxies; many cancellations with no message point at timeouts and task-group failures — look for the corresponding TimeoutError or exception group in the same request.
Pitfalls & edge cases¶
- Expecting a message from timeouts.
wait_forandasyncio.timeoutsent none in testing. - Expecting the latest reason. The first of two messages won.
- Swallowing after reading the reason. The message never makes swallowing safe.
- Unnamed tasks. The reason without the task name is half the story.
Frequently Asked Questions¶
How do I pass a reason when cancelling an asyncio task?
Call task.cancel(msg="reason"). The CancelledError inside the task and the one seen by whoever awaits the task both carry the message in args, as tested on Python 3.11 to 3.14.
Why is CancelledError.args empty?
The cancellation came from code that did not set a message: asyncio.wait_for or asyncio.timeout expiring, or a TaskGroup cancelling siblings after a failure. All produced empty args in testing.
What happens if a task is cancelled twice with different messages?
The first message is delivered; in testing cancel("first") then cancel("second") produced ('first',) inside the task and at the awaiter.
Does cancelling asyncio.gather pass the message to its children?
Yes. Cancelling the gather future with a message delivered that message to every child in testing.
Related¶
- Cancellation Patterns — up to the topic overview.
- Cancelling all tasks on KeyboardInterrupt — a shutdown path that benefits from reasons.
- Resilience, Cancellation & Error Handling — the section overview.