Skip to content

Using AnyIO Cancel Scopes and move_on_after

A cancel scope is the idea trio contributed to async Python: cancellation as a region of code with an owner, rather than an exception aimed at a task. The region can be given a deadline, cancelled by anyone holding a reference, nested inside other regions with predictable precedence, and shielded so that cleanup finishes even while the outside world is cancelling. asyncio.timeout() covers the common case of "raise if this takes too long"; cancel scopes cover the other four. Every behaviour below was verified on both AnyIO backends, producing identical output on asyncio and on trio.

Prerequisites

The cancel scope API A grid of 5 rows by 2 columns. The cancel scope API construct on expiry use for move_on_after(n) exits quietly; cancelled_caught is True optional work fail_after(n) raises TimeoutError a hard deadline CancelScope() nothing until you cancel() it cancelling from elsewhere CancelScope(shield=True) ignores outer cancellation cleanup that must finish scope.deadline = ... moves the deadline extending on progress Verified identically on both backends: move_on_after at 0.10 s, fail_after raising at 0.10 s.

1. Choose between swallowing and raising

The two timeout helpers differ only in what happens at the deadline:

with anyio.move_on_after(0.1) as scope:
    await slow_operation()
if scope.cancelled_caught:
    logger.info("skipped the optional step")           # no exception was raised

with anyio.fail_after(0.1):
    await required_operation()                         # raises TimeoutError

Verified: move_on_after exited at 0.10 s with cancelled_caught=True and no exception; fail_after raised TimeoutError at 0.10 s. Both are synchronous context managers — with, not async with — because entering them does not await.

The choice is a statement about the work. Optional enrichment, a best-effort cache read, a nice-to-have prefetch: move_on_after, then check the flag. Anything the caller's result depends on: fail_after, and let the error propagate.

cancelled_caught is the part people forget. Without checking it, a move_on_after block silently produces a half-computed result that later code treats as complete.

Verify: each timeout site either checks cancelled_caught or uses fail_after.

2. Nest scopes and know which one fired

Scopes nest, and each cancels only its own region:

with anyio.move_on_after(1.0) as outer:
    with anyio.move_on_after(0.1) as inner:
        await slow()
    # execution continues here when the INNER deadline fires
    await something_else()

Measured: the inner deadline fired at 0.10 s with inner.cancelled_caught=True and outer.cancelled_caught=False, the code after the inner block ran, and the outer scope exited normally at 0.15 s.

That is the property that makes scopes composable. An inner timeout on one step does not abandon the whole operation, and an outer deadline still bounds everything — including the parts that come after an inner timeout fired. Each scope reports independently, so "which deadline was hit" is answerable, unlike nested asyncio.timeout blocks where a caught TimeoutError could have come from any level.

Verify: with nested scopes, exactly the expected cancelled_caught flag is True.

Nested scopes: the inner one fires alone 3 lanes over time. Nested scopes: the inner one fires alone outer, 1 s active exits normally inner, 0.1 s active cancels the code sleeping continues time → Measured: inner fired at 0.10 s, the outer scope exited at 0.15 s with cancelled_caught False.

3. Move the deadline while the block runs

scope.deadline is writable, which gives an idle timeout in two lines:

with anyio.move_on_after(0.2) as scope:
    async for chunk in stream:
        await handle(chunk)
        scope.deadline = anyio.current_time() + 0.2    # extend on every chunk

Verified: five iterations each sleeping 0.1 s ran for 0.50 s under a nominal 0.2 s window, with cancelled_caught=False — the deadline measured silence rather than duration, exactly as intended.

anyio.current_time() is the backend's clock, and the deadline is absolute; scope.deadline = math.inf disables it entirely. The same pattern exists in the standard library as rescheduling asyncio.timeout deadlines, which arrived later and with a method rather than an attribute.

Verify: a stream that keeps delivering runs past the nominal window, and one that goes quiet is cancelled one window later.

4. Shield the cleanup that must finish

Cancellation arriving while you are releasing a lock, committing a transaction or sending a final message is the case that shielding exists for:

try:
    await do_work()
finally:
    with anyio.CancelScope(shield=True):               # ignores outer cancellation
        await release_lease()

Verified: with an outer move_on_after(0.1) and a shielded cleanup that sleeps 0.15 s, the cleanup completed and the whole block returned at 0.25 s. Without the shield it would have been cancelled at its first await.

Two rules keep shielding safe. Keep it small — the enclosing deadline no longer applies inside it, so a shielded block that hangs hangs forever. And give it its own bound where the work can be slow:

    with anyio.CancelScope(shield=True):
        with anyio.move_on_after(2.0):                 # bounded even while shielded
            await release_lease()

asyncio.shield() is the standard library's answer, and it differs: it protects a future from cancellation while the awaiting code is still cancelled, whereas a shielded scope protects the code itself.

Verify: cleanup completes under cancellation, and the shielded region has its own deadline.

5. Cancel a scope from somewhere else

A scope is an object, so anything holding it can cancel it:

async def supervisor(scope: anyio.CancelScope) -> None:
    await shutdown_event.wait()
    scope.cancel()                                     # stops the region, from outside


with anyio.CancelScope() as scope:
    async with anyio.create_task_group() as tg:
        tg.start_soon(supervisor, scope)
        await serve_forever()

Verified: a watcher task calling scope.cancel() after 0.08 s exited the block at 0.08 s with cancelled_caught=True.

This is the piece with no direct asyncio equivalent. In asyncio you cancel a task, which means arranging for the right task handle to be in the right place and accepting that the cancellation lands wherever that task currently is. A scope names the region instead, which is both more precise and easier to reason about — the same shape as cancelling a TaskGroup from inside a child, expressed directly.

Verify: cancelling the scope ends exactly the intended region, with nothing outside it affected.

Cancel scopes against asyncio.timeout 2 columns contrasting anyio cancel scopes, asyncio.timeout. Cancel scopes against asyncio.timeout anyio cancel scopes a first-class object move_on_after swallows, fail_after raises cancel() callable from another task shield=True protects cleanup deadline is mutable asyncio.timeout a context manager always raises TimeoutError no external cancel; cancel the task shield is a separate function reschedule() moves the deadline The stdlib has caught up on deadlines; shielding and external cancellation are still simpler here.
Which scope does this need? A decision on What happens when time runs out with 3 outcomes. Which scope does this need? What happens when time runs out? carry on without the result move_on_after check cancelled_caught the operation has failed fail_after TimeoutError propagates something else decides CancelScope cancel() it from anywhere A fourth case — cleanup that must survive cancellation — is CancelScope(shield=True).

Verification

Cancel scopes are used correctly when:

  • cancelled_caught is checked after every move_on_after.
  • fail_after is used where the result is required.
  • Nesting is deliberate, with the expected scope reporting the catch.
  • Shielded regions are small and separately bounded.
  • Scopes cancelled externally are held by exactly one owner.
  • Behaviour is identical on both backends, tested.

Pitfalls & edge cases

  • Ignoring cancelled_caught. A move_on_after block that timed out looks like one that succeeded.
  • async with on a scope. They are synchronous context managers; async with raises.
  • A large shielded region. The enclosing deadline does not apply inside it, so it can hang indefinitely.
  • Reusing a scope object. Each scope is entered once; create a new one per region.
  • Entering a scope in one task and exiting in another. Scopes are bound to the task that entered them.
  • Expecting move_on_after to stop CPU work. Cancellation is delivered at an await, exactly as in asyncio.

Frequently Asked Questions

What is the difference between move_on_after and fail_after?

move_on_after exits the block quietly when the deadline passes and sets scope.cancelled_caught to True; fail_after raises TimeoutError. Verified, both fired at 0.10 s for a 0.1 s deadline. Use the first for optional work and the second when the caller's result depends on it.

How do nested AnyIO cancel scopes interact?

Each cancels only its own region. Measured with a 0.1 s scope inside a 1 s scope, the inner fired at 0.10 s with its own cancelled_caught True, code after the inner block continued, and the outer exited normally at 0.15 s with its flag False — so you always know which deadline was hit.

How do I extend a deadline while the block is running?

Assign to scope.deadline: scope.deadline = anyio.current_time() + n. That turns a total timeout into an idle timeout — verified, five 0.1 s iterations ran for 0.50 s under a nominal 0.2 s window without being cancelled.

How do I make cleanup survive cancellation in AnyIO?

Wrap it in with anyio.CancelScope(shield=True). Verified, a 0.15 s shielded cleanup completed even though the enclosing scope had a 0.1 s deadline. Keep shielded regions small and give them their own move_on_after, because the outer deadline no longer applies inside.

Can I cancel an AnyIO scope from another task?

Yes — that is the main thing scopes offer over asyncio.timeout. Pass the scope object to whatever should cancel it and call scope.cancel(). Verified, a watcher task cancelled the region after 0.08 s and the block exited immediately with cancelled_caught True.