Skip to content

Level vs Edge Cancellation in AnyIO and asyncio

asyncio and AnyIO both cancel a task by raising an exception at an await, and code written for one usually looks right on the other. The difference is in what happens after that first exception. asyncio cancellation is edge-triggered: task.cancel() delivers one CancelledError, and if the task catches it, later awaits run normally. AnyIO and trio cancellation is level-triggered: once a cancel scope is cancelled, every await inside it raises again until the code leaves the scope. Verified side by side: in asyncio, a task that caught its CancelledError and awaited a 10 ms cleanup ran the cleanup and returned normally; in an AnyIO cancel scope, on both the asyncio and trio backends, the same cleanup await was cancelled again — and ran only inside a shielded scope. Cleanup code that awaits is where porting bugs live.

Prerequisites

1. Reproduce the difference

import asyncio
import anyio


async def asyncio_style() -> str:
    async def work():
        try:
            await asyncio.sleep(10)
        except asyncio.CancelledError:
            await asyncio.sleep(0.01)                 # cleanup await
            return "cleanup ran, returned normally"
    t = asyncio.create_task(work())
    await asyncio.sleep(0)
    t.cancel()
    return await t


async def anyio_style() -> list[str]:
    out = []
    with anyio.CancelScope() as scope:
        scope.cancel()
        try:
            await anyio.sleep(10)
        except anyio.get_cancelled_exc_class():
            try:
                await anyio.sleep(0.01)
                out.append("cleanup ran")
            except anyio.get_cancelled_exc_class():
                out.append("cleanup cancelled again")
            raise
    return out


print(asyncio.run(asyncio_style()))            # cleanup ran, returned normally
print(anyio.run(anyio_style))                  # ['cleanup cancelled again']

The asyncio example also shows edge cancellation's main hazard: the task swallowed its cancellation and returned a value, so its canceller cannot tell it was interrupted. Level cancellation makes that impossible inside a scope — any await keeps raising — at the price that legitimate cleanup awaits need explicit protection.

Verify: run both; the outputs differ exactly as shown, and the AnyIO result is the same with backend="trio".

The same cleanup await under two cancellation models 2 lanes over time. The same cleanup await under two cancellation models asyncio task await sleep CancelledError once cleanup await runs AnyIO scope await sleep Cancelled cleanup raises again until scope exit time → Edge cancellation fires once; level cancellation stays asserted for the whole scope.

2. Shield cleanup that must await

In AnyIO, cleanup that needs to await — closing a connection gracefully, sending a final message, rolling back — goes inside a shielded scope. Shielded scopes are immune to cancellation from outside, so their awaits run; give them their own deadline so they cannot hang:

async def handle(conn) -> None:
    try:
        await serve_requests(conn)
    finally:
        with anyio.move_on_after(2, shield=True):     # runs even though we are being cancelled
            await conn.send_goodbye()
            await conn.aclose()

Verified on both backends: the shielded cleanup await ran inside a cancelled outer scope. Without shield=True, the send_goodbye() await raises immediately and the connection is torn down without the goodbye — and aclose() never runs at all.

The asyncio analogue is asyncio.shield(), which protects an inner task rather than a block of code and has different semantics; for code that will run on AnyIO, use shielded scopes rather than asyncio.shield(). The asyncio patterns are in using asyncio.shield to protect critical sections.

Verify: cancel the handler during a request; the peer receives the goodbye and the connection closes cleanly.

3. Stop swallowing cancellation when porting

Edge-triggered habits from asyncio become bugs under AnyIO. Three common ones:

# 1. "Log and continue" — asyncio lets the loop keep running; AnyIO cancels the next await anyway
while True:
    try:
        item = await queue.get()
    except asyncio.CancelledError:
        log.info("cancelled, continuing")             # wrong in both; endless loop in asyncio
        continue

# 2. Catching broad exceptions that include cancellation
try:
    await fetch()
except BaseException:                                  # catches Cancelled on every backend
    metrics.inc("errors")

# 3. Awaiting in except/finally without a shield
try:
    await work()
finally:
    await flush_logs()                                 # silently skipped under AnyIO when cancelled

Fix them the same way on every backend: always re-raise the cancellation exception, catch Exception rather than BaseException, and put awaiting cleanup in a shielded scope with a timeout. Use anyio.get_cancelled_exc_class() in backend-neutral code, since the class differs: asyncio.CancelledError on asyncio, trio.Cancelled on trio. The asyncio-only version of this checklist is preventing CancelledError leaks in cleanup.

Verify: grep for except asyncio.CancelledError and except BaseException in code that also runs under AnyIO; every hit should re-raise.

Edge and level cancellation side by side A grid of 5 rows by 3 columns. Edge and level cancellation side by side property asyncio (edge) AnyIO / trio (level) delivery one CancelledError per cancel() every await in the scope await after catching runs normally raises again swallowing cancellation possible, silent bug impossible inside the scope awaiting cleanup just works needs a shielded scope exception class asyncio.CancelledError get_cancelled_exc_class() Level cancellation is stricter; ported cleanup code is where the difference shows.

4. Understand asyncio's partial move toward levels

asyncio 3.11 added pieces that narrow the gap. Task.cancelling() counts pending cancellation requests and Task.uncancel() decrements it; asyncio.timeout() and TaskGroup use them to tell "my own cancellation" from "someone else's". When code catches a CancelledError and does not re-raise, the task's cancelling() count stays above zero, and structured primitives notice:

async def stubborn():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        pass                                       # swallowed
    print(asyncio.current_task().cancelling())     # 1 — the request is still recorded

That count is what lets an enclosing asyncio.timeout() re-raise correctly even when inner code misbehaves in some cases. It is still not level-triggered: the next await in stubborn() runs normally. The full semantics are in understanding Task.cancelling and uncancel.

Verify: after swallowing a cancellation, cancelling() is 1 and the next await completes.

5. Write cleanup that is correct under both

Backend-neutral libraries should assume level semantics, because code correct under levels is also correct under edges:

import anyio


async def with_connection(pool, fn):
    conn = await pool.acquire()
    try:
        return await fn(conn)
    except anyio.get_cancelled_exc_class():
        with anyio.CancelScope(shield=True):
            await conn.rollback()                   # protected from the outer cancellation
        raise                                       # always propagate
    finally:
        with anyio.move_on_after(1, shield=True):
            await pool.release(conn)

Under asyncio this behaves like careful asyncio code; under trio it behaves like careful trio code. The habits — re-raise, shield awaited cleanup, bound shielded work with a timeout — are the same ones that make plain asyncio code survive TaskGroup and asyncio.timeout() cancellations. For testing across both backends, see testing async code with the AnyIO pytest plugin.

Verify: run the same cancellation test on both backends; both leave the connection rolled back and released.

Cleanup that survives level cancellation A flow of 3 stages. Cleanup that survives level cancellation catch get_cancelled_exc_class() never Exception-wide shielded scope + timeout cleanup awaits run re-raise cancellation continues Shield the cleanup, bound it, and always re-raise; that is correct on asyncio and trio alike.

Verification

Cancellation handling is portable when:

  • Every caught cancellation is re-raised, using get_cancelled_exc_class() in neutral code.
  • Every awaited cleanup sits inside a shielded scope with a timeout.
  • No except BaseException swallows cancellation.
  • Cancellation tests pass on both backends, not only asyncio.

Diagnostic Hook: in tests, after cancelling a component, assert that its task ended cancelled rather than returning a value; on asyncio, also assert cancelling() == 0 for tasks that should have unwound cleanly. In production, log shielded cleanup that hits its timeout — each one is a resource that may have been left in an unknown state.

Pitfalls & edge cases

  • Awaiting in finally without a shield under AnyIO. The await raises immediately; the cleanup is skipped.
  • Unbounded shielded scopes. A hung cleanup now blocks cancellation forever; always add a timeout.
  • Mixing asyncio.shield() into AnyIO code. It protects a separate task, not the current scope, and does not compose with cancel scopes.
  • Catching Exception and expecting it to catch trio cancellation. trio.Cancelled is a BaseException, like asyncio.CancelledError.

Frequently Asked Questions

What is level-triggered cancellation?

Once a cancel scope is cancelled, every await inside it raises the cancellation exception until the code exits the scope. AnyIO and trio work this way. asyncio is edge-triggered: one cancel() delivers one CancelledError.

Why does my cleanup await fail under AnyIO but work in asyncio?

Under AnyIO's level cancellation, an await inside a cancelled scope raises again, so cleanup awaits in except or finally blocks are cancelled too. Put them in a CancelScope with shield=True, ideally with a timeout.

How do I catch cancellation in backend-agnostic AnyIO code?

Catch anyio.get_cancelled_exc_class(), which returns asyncio.CancelledError or trio.Cancelled depending on the backend, and always re-raise it.

Can asyncio code swallow cancellation?

Yes. If a task catches CancelledError and does not re-raise, it keeps running and can return normally. Task.cancelling() still records the request, which asyncio.timeout and TaskGroup use, but later awaits are not cancelled.