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¶
- Python 3.11+ with
anyio(pip install anyio), andtrioif you want to run the second backend. - AnyIO basics from writing backend-agnostic code with AnyIO.
- Cancellation semantics from Cancellation Patterns.
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.
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.
Verification¶
Cancel scopes are used correctly when:
cancelled_caughtis checked after everymove_on_after.fail_afteris 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. Amove_on_afterblock that timed out looks like one that succeeded. async withon a scope. They are synchronous context managers;async withraises.- 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_afterto 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.
Related¶
- AnyIO & Trio Interop — up to the topic overview.
- Writing backend-agnostic code with AnyIO — the rest of the API.
- Rescheduling asyncio.timeout deadlines — the standard library equivalent.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.