Skip to content

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

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.

How asyncio.timeout tells its own cancellation apart A sequence of 5 messages between 3 participants. How asyncio.timeout tells its own cancellation apart timeout block task event loop enter: remember cancelling() = n deadline: cancel(), count n+1 CancelledError reaches __aexit__ uncancel(): count back to n? yes -> TimeoutError; no -> CancelledError The counter is how structured concurrency knows whose cancellation it is.

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.

A swallowed cancel, then a TaskGroup whose child fails A grid of 4 rows by 3 columns. A swallowed cancel, then a TaskGroup whose child fails Python swallowed, no uncancel() swallowed + uncancel() 3.11 ExceptionGroup(ValueError) ExceptionGroup(ValueError) 3.12 ExceptionGroup(ValueError) ExceptionGroup(ValueError) 3.13 CancelledError escaped ExceptionGroup(ValueError) 3.14 CancelledError escaped ExceptionGroup(ValueError) The same code changes behaviour across versions unless the counter is kept honest.

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.

What should this except CancelledError block do? A decision on Why is the code catching CancelledError with 3 outcomes. What should this except CancelledError block do? Why is the code catching CancelledError? to clean up clean up, then raise the default to abandon one item and continue uncancel(), continue rare, document it to know why finally runs read cancelling() metrics, shielding Swallowing without uncancel() breaks timeout and TaskGroup bookkeeping.

Verification

Cancellation bookkeeping is correct when:

  • Every except CancelledError re-raises, or calls uncancel() 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 raised CancelledError instead of the child's error.
  • Swallowing inside asyncio.timeout. The block exited normally with no TimeoutError.
  • Expecting except CancelledError to reset the count. Only uncancel() 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.