asyncio.exceptions.CancelledError in Python AsyncIO Tasks
When catching asyncio.CancelledError for cleanup in a coroutine, always re-raise it (raise) so the event loop knows the task cancellation succeeded, or use asyncio.shield() to protect critical operations.
Root Cause Analysis
This error occurs when an asyncio task or coroutine is cancelled from the outside via task.cancel() or an enclosing asyncio.wait_for() timeout, causing Python to inject asyncio.CancelledError into the awaiting coroutine at its next await point.
The Lifecycle of AsyncIO Task Cancellation
In Python's asynchronous model, cooperative multitasking requires tasks to be interrupted cleanly. When task.cancel() is called:
- The task is marked as cancelled in the event loop queue.
- When the coroutine reaches its next
awaitexpression, the event loop raisesasyncio.CancelledErrorinside the coroutine frame. - In Python 3.8+,
CancelledErrorinherits directly fromBaseException(likeKeyboardInterrupt), notException, to prevent genericexcept Exception:blocks from accidentally swallowing cancellations.
How the Exception Leaks
- Uncaught Task Exceptions: Calling
await taskafter it was cancelled without handling the cancellation. - Timeouts in
asyncio.wait_for(): When a timeout triggers,wait_forcancels the inner task and raisesTimeoutError. - Event Loop Shutdown: When the application receives a SIGINT/SIGTERM, graceful shutdown routines cancel all pending background tasks.
Reproduction Code (MCVE)
import asyncio
async def long_running_task():
await asyncio.sleep(5)
async def runner():
task = asyncio.create_task(long_running_task())
await asyncio.sleep(0.01)
task.cancel()
# Awaiting a cancelled task raises asyncio.CancelledError
await task
asyncio.run(runner())
Solution 1: Clean Up Resources and Re-Raise `CancelledError`
Perform cleanup inside finally or except asyncio.CancelledError: and re-raise the exception to allow clean task termination.
import asyncio
async def resilient_worker():
try:
print('Worker started running...')
await asyncio.sleep(0.05)
except asyncio.CancelledError:
print('Cancellation requested: closing sockets and flushing buffers.')
# Always re-raise to complete task cancellation protocol
raise
finally:
print('Final cleanup executed.')
async def main():
task = asyncio.create_task(resilient_worker())
await asyncio.sleep(0.01)
task.cancel()
try:
await task
except asyncio.CancelledError:
print('Parent successfully handled child task cancellation.')
asyncio.run(main())
Solution 2: Protect Critical Database Writes with `asyncio.shield()`
Use asyncio.shield() to prevent a crucial background operation (such as a database commit) from being aborted if the outer request times out.
import asyncio
async def critical_database_write():
print('Beginning critical transactional write...')
await asyncio.sleep(0.02)
print('Critical write saved to disk.')
async def handle_request():
# Shield protects critical_database_write from cancellation by outer timeouts
await asyncio.shield(critical_database_write())
asyncio.run(handle_request())
Common Pitfalls & Python 3.8+ Changes
A dangerous anti-pattern is swallowing CancelledError without re-raising (except asyncio.CancelledError: pass). In Python 3.8+, if a coroutine swallows CancelledError and returns a normal value, the task is considered non-cancellable, and the event loop will raise RuntimeError: Task was cancelled but returned a value.
Contrasting CancelledError with TimeoutError:
CancelledError: Raised inside the cancelled task to stop execution.TimeoutError: Raised byasyncio.wait_for()to the caller when the duration limit is reached.