Understanding Task.cancelling() and uncancel()¶
Since Python 3.11, every task keeps a count of cancellation requests: cancel() increments it, uncancel() decrements it, and cancelling() reads it. asyncio.timeout and TaskGroup rely on that count to tell their own cancellations apart from cancellations requested by someone else. Code that catches CancelledError and carries on — deliberately, to survive a cancel — leaves the count raised unless it calls uncancel(), and the structured-concurrency tools then misread the situation. Tested across versions: a task that swallowed a cancellation and later ran a TaskGroup whose child raised ValueError got the expected ExceptionGroup on Python 3.11 and 3.12 — and on 3.13 and 3.14, a CancelledError escaped the task instead. Calling uncancel() after swallowing the cancel restored the correct behaviour on every version. A second trap: inside asyncio.timeout(0.1), a coroutine that swallowed the cancellation made the block exit normally after 0.1 s, with no TimeoutError at all. This guide explains the counter and when to touch it.
Prerequisites¶
- Python 3.11+; behaviour compared on 3.11, 3.12, 3.13 and 3.14.
- Cancellation basics, from cancelling a task and waiting for it to finish.
- TaskGroup semantics, from Exception Groups & TaskGroups.
1. See what the counter records¶
Each cancel() call on a task adds one to its count, whether or not a cancellation is already pending:
async def sleeper():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print(asyncio.current_task().cancelling()) # measured: 2
raise
task = asyncio.create_task(sleeper())
await asyncio.sleep(0)
task.cancel()
task.cancel() # a second request
Measured: after two cancel() calls the task saw cancelling() == 2, and the count stays after the task finishes. A count above zero means "someone has asked this task to stop and the request has not been withdrawn". It does not decrease when CancelledError is caught — only uncancel() does that.
Verify: in a debugger or log, cancelling() inside an except CancelledError block equals the number of outstanding cancel requests.
2. Understand how timeout and TaskGroup use it¶
asyncio.timeout cancels its own task when the deadline passes, then has to decide in __aexit__ whether the CancelledError it sees is the one it caused. It records the count on entry, calls uncancel() when it fires, and converts the error to TimeoutError only if the count has returned to its entry value:
async with asyncio.timeout(1.0): # remembers cancelling() on entry
await work() # on expiry: task.cancel(); count +1
# __aexit__: count back to the entry value after uncancel()? -> TimeoutError
# otherwise someone else also cancelled -> CancelledError propagates
That is why an external cancel() during a timeout still propagates as CancelledError — tested, a task cancelled from outside while inside asyncio.timeout(5) saw CancelledError, not TimeoutError. TaskGroup does the same bookkeeping when it cancels its parent task after a child fails: it must know whether the parent was also cancelled from outside, so it can re-raise CancelledError rather than report only the children's errors.
Verify: an external cancel of a task blocked inside asyncio.timeout surfaces as CancelledError in the caller.
3. Call uncancel() when you deliberately swallow a cancellation¶
Swallowing CancelledError is occasionally right: a supervisor loop that treats "cancel" as "abandon the current item and continue", or a request handler that turns a cancelled sub-step into a fallback. In those cases, withdraw the request so the count stays truthful:
async def resilient_loop() -> None:
while True:
item = await queue.get()
try:
await process(item)
except asyncio.CancelledError:
if not abandon_requested(item):
raise # a real shutdown: let it propagate
asyncio.current_task().uncancel() # we handled this one: withdraw it
log.info("abandoned %s", item)
Tested: a loop that caught three cancellations and called uncancel() after each saw cancelling() go 1 → 0 every time and finished normally. Without uncancel(), the count stays raised, and on Python 3.13 and 3.14 the next TaskGroup in that task misbehaved: when a child raised ValueError, the task ended with CancelledError instead of the ExceptionGroup — the group assumed the leftover request was a genuine outside cancellation. On 3.11 and 3.12 the same code still produced the ExceptionGroup, so the bug appears on upgrade.
Verify: after handling a cancellation you intend to swallow, asyncio.current_task().cancelling() is back to its previous value.
4. Never swallow cancellation inside a timeout block¶
A coroutine that catches CancelledError and returns normally defeats any asyncio.timeout around it, silently:
async def swallow():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
return "swallowed" # looks harmless...
async with asyncio.timeout(0.1):
result = await swallow()
# measured: the block exits normally after 0.1 s with result == "swallowed"; no TimeoutError
The timeout cancelled the task, the coroutine absorbed the cancellation and returned a value, and the block completed as if nothing had happened — the caller gets a made-up result instead of an error. Library code is the usual culprit: a helper that catches CancelledError to "clean up" and forgets to re-raise. The rule is simple: catch CancelledError only to clean up, and re-raise it; the patterns are in preventing CancelledError leaks in cleanup.
Verify: grep for except asyncio.CancelledError and except BaseException; every block either re-raises or calls uncancel() deliberately with a comment explaining why.
5. Use the counter for diagnostics, not control flow¶
cancelling() is useful when cleanup code needs to know why it is running:
async def handler(request):
try:
return await do_work(request)
finally:
task = asyncio.current_task()
if task.cancelling():
REQUESTS_CANCELLED.inc() # client went away, or shutdown
await asyncio.shield(release_resources(request))
else:
await release_resources(request)
Reading the counter in finally distinguishes "finished or failed" from "being cancelled", which decides whether cleanup must be shielded and whether to record a cancellation metric. Avoid building logic that depends on exact counts beyond zero versus non-zero; the exact numbers are bookkeeping for asyncio.timeout and TaskGroup, and other libraries (anyio, trio adapters) may manipulate them too.
Verify: cancellation metrics count client disconnects and shutdown cancellations, and nothing else.
Verification¶
Cancellation bookkeeping is correct when:
- Every
except CancelledErrorre-raises, or callsuncancel()deliberately. - No coroutine returns normally from a cancellation inside a timeout's scope.
- Code paths that swallow cancellation are tested on Python 3.13+, where the consequences differ.
cancelling()is used for diagnostics, not exact-count logic.
Diagnostic Hook: in tests, assert asyncio.current_task().cancelling() == 0 at the end of code paths that handle cancellation internally. A non-zero count is a leftover request that will change how the next asyncio.timeout or TaskGroup in that task behaves — on 3.13 and later, by letting CancelledError escape in place of real errors.
Pitfalls & edge cases¶
- Swallowing without
uncancel(). On 3.13+, a later TaskGroup raisedCancelledErrorinstead of the child's error. - Swallowing inside
asyncio.timeout. The block exited normally with noTimeoutError. - Expecting
except CancelledErrorto reset the count. Onlyuncancel()does. - Testing only on older Pythons. The TaskGroup behaviour changed in 3.13.
Frequently Asked Questions¶
What does Task.cancelling() return?
The number of pending cancellation requests for the task: each cancel() adds one and each uncancel() removes one. In testing, two cancel() calls gave cancelling() == 2.
When should I call Task.uncancel()?
Only when you deliberately swallow a CancelledError and continue running, so asyncio.timeout and TaskGroup can still tell their own cancellations apart from outside ones.
Why does my TaskGroup raise CancelledError instead of the child's exception?
On Python 3.13 and later this happens when the task earlier swallowed a cancellation without calling uncancel(); the leftover count makes the group assume an outside cancellation. Calling uncancel() fixed it in testing.
Why didn't asyncio.timeout raise TimeoutError?
The code inside the block caught CancelledError and returned normally, so the timeout's cancellation never reached it. In testing the block exited after 0.1 s with the swallowed result.
Related¶
- Cancellation Patterns — up to the topic overview.
- Telling TimeoutError apart from CancelledError — the same bookkeeping from the timeout side.
- Resilience, Cancellation & Error Handling — the section overview.