Telling TimeoutError Apart from CancelledError¶
A timeout in asyncio is implemented as a cancellation, so the two exceptions are related in ways that confuse error handling. Tested on Python 3.14: inside a coroutine stopped by asyncio.wait_for or asyncio.timeout, the exception was CancelledError; the caller of wait_for or the async with asyncio.timeout(...) block received TimeoutError, with the CancelledError attached as its __context__; the timeout's context manager reported expired() == True. When the task was cancelled from outside while inside an asyncio.timeout(5) block, the caller got CancelledError and expired() was False. Since Python 3.11, asyncio.TimeoutError and concurrent.futures.TimeoutError are both the builtin TimeoutError — on 3.10 they were three different classes. And except Exception caught the TimeoutError but would never catch CancelledError, which derives from BaseException. This guide uses those facts to handle each case correctly.
Prerequisites¶
- Python 3.11+ (the exception aliases and
asyncio.timeout); behaviour compared with 3.10. - Timeout tools, from choosing asyncio.timeout vs wait_for.
- Cancellation counting, from understanding Task.cancelling() and uncancel().
1. Know which side sees which exception¶
The code being timed out is cancelled; the code that set the timeout gets the timeout:
async def fetch():
try:
await slow_call()
except asyncio.CancelledError:
# tested: this is what the timed-out code sees - a cancellation
await asyncio.shield(cleanup())
raise
try:
async with asyncio.timeout(0.05):
await fetch()
except TimeoutError as exc:
# tested: the caller sees TimeoutError; exc.__context__ is the CancelledError
log.warning("fetch timed out")
That split is deliberate: the inner code only needs to know "stop now", for which cancellation is the universal mechanism; the outer code needs to know why, and the timeout converts the cancellation into TimeoutError at the boundary where the deadline was set. Inner code therefore handles timeouts and cancellations identically — clean up and re-raise — and should never try to tell them apart.
Verify: a test that times out a coroutine sees CancelledError inside it and TimeoutError at the timeout boundary.
2. Distinguish a timeout from an outside cancellation¶
At the boundary, a CancelledError can mean two things: the timeout fired (converted to TimeoutError for you), or someone else cancelled the task — a client disconnect, a shutdown, a TaskGroup. asyncio keeps them apart, and the timeout object can tell you which happened:
async def handler(request):
try:
async with asyncio.timeout(5) as deadline:
return await process(request)
except TimeoutError:
return Response(status_code=504) # our deadline
except asyncio.CancelledError:
# tested: an outside cancel inside timeout(5) arrives as CancelledError,
# and deadline.expired() is False
log.info("request cancelled (client gone or shutdown)")
raise # never swallow it
Tested: cancelling the task from outside while it was inside asyncio.timeout(5) produced CancelledError, with expired() returning False in a finally block. The timeout uses the task's cancellation counter to decide whether a cancellation is its own; a timeout and an outside cancel arriving together correctly yield CancelledError, because the outside request must not be lost.
Verify: a test that cancels a handler during its timeout block sees CancelledError, and one that lets the deadline pass sees TimeoutError.
3. Catch the right class on every Python you support¶
Before 3.11, asyncio had its own TimeoutError class; since 3.11 they are all the builtin:
import asyncio
import concurrent.futures
# Python 3.11+ (tested on 3.14): all True
asyncio.TimeoutError is TimeoutError
concurrent.futures.TimeoutError is TimeoutError
# Python 3.10 (tested): both False - `except TimeoutError` misses asyncio.wait_for timeouts
try:
await asyncio.wait_for(call(), 2)
except asyncio.TimeoutError: # works on every version: on 3.11+ it is TimeoutError
...
On 3.11 and later, except TimeoutError is enough. Code that must also run on 3.10 should catch asyncio.TimeoutError, which is the builtin on newer versions and the asyncio-specific class on older ones. Libraries layer their own timeouts on top — httpx.TimeoutException, aiohttp.ServerTimeoutError (which derives from asyncio.TimeoutError) — so check what a client raises rather than assuming. Version differences of this kind are collected in Asyncio Version Migration.
Verify: a timeout test runs on the oldest Python version you support.
4. Never let generic handlers swallow cancellation¶
CancelledError derives from BaseException (since 3.8), so except Exception does not catch it — which is the point. Code that catches BaseException, or uses a bare except:, catches it and can break timeouts and shutdown:
async def risky():
try:
await work()
except Exception: # catches TimeoutError and errors; lets cancellation pass
log.exception("work failed")
return None
async def broken():
try:
await work()
except BaseException: # also catches CancelledError: swallowed
log.exception("work failed")
return None # the timeout around this call now "succeeds"
Tested in the cancellation patterns section: swallowing the cancellation inside an asyncio.timeout made the block exit normally with no TimeoutError. If you must catch BaseException — top-level task supervisors, for example — re-raise CancelledError explicitly. Linters (ruff's BLE001, flake8-bugbear's B036) flag broad except clauses; enable them.
Verify: grep for except BaseException and bare except: in async code; each one re-raises CancelledError.
5. Report the two differently¶
Timeouts and cancellations mean different things operationally and should be recorded differently:
async def call_dependency(name: str, fn):
try:
async with asyncio.timeout(TIMEOUTS[name]):
return await fn()
except TimeoutError:
DEPENDENCY_TIMEOUTS.labels(name=name).inc() # a dependency is slow
raise
except asyncio.CancelledError:
CALLS_CANCELLED.labels(name=name).inc() # our caller gave up
raise
A rising timeout count says a dependency is slow — alert on it, tune the timeout, consider a breaker. A rising cancellation count says your callers are giving up — clients disconnecting, upstream deadlines expiring — which points at your own latency or at aggressive client timeouts. Mixing them in one "errors" metric hides both signals. Deadlines passed from upstream, which turn into cancellations here, are covered in propagating deadlines across async service calls.
Verify: dashboards show timeouts per dependency and cancellations per endpoint as separate series.
Verification¶
Timeouts and cancellations are handled correctly when:
- Timed-out code treats
CancelledErroras "stop" and re-raises after cleanup. - Boundaries catch
TimeoutErrorfor their own deadlines and re-raiseCancelledError. - The right class is caught on every supported Python version.
- No broad
exceptswallows cancellation, and metrics separate the two.
Diagnostic Hook: for each TimeoutError logged, include exc.__context__ in the log; it is the CancelledError raised inside the timed-out code, and its traceback shows exactly which await was in progress when the deadline passed — the slow step.
Pitfalls & edge cases¶
- Catching
TimeoutErrorinside the timed-out code. It seesCancelledErrorinstead. except TimeoutErroron 3.10. It missesasyncio.TimeoutError.except BaseExceptionwithout re-raising. Cancellation and timeouts break.- One metric for both. Slow dependencies and impatient callers look the same.
Frequently Asked Questions¶
Why does my coroutine get CancelledError instead of TimeoutError?
Timeouts are implemented by cancelling the task. The timed-out code sees CancelledError; the code that set the timeout receives TimeoutError, with the CancelledError as its context, as tested on Python 3.14.
Is asyncio.TimeoutError the same as TimeoutError?
Since Python 3.11, yes; both it and concurrent.futures.TimeoutError are the builtin. On 3.10 they were distinct classes, so catch asyncio.TimeoutError if you support it.
How do I know whether asyncio.timeout fired or the task was cancelled from outside?
The block raises TimeoutError only for its own deadline; an outside cancel propagates as CancelledError. The context manager's expired() method also reports whether the deadline passed.
Does except Exception catch asyncio.CancelledError?
No. CancelledError derives from BaseException, so except Exception lets it propagate, which is what you want.
Related¶
- Timeouts & Deadlines — up to the topic overview.
- Timing out each item of an async iterator — where the cancellation side has visible consequences.
- Resilience, Cancellation & Error Handling — the section overview.