Skip to content

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

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.

Who sees what when a timeout fires A sequence of 5 messages between 3 participants. Who sees what when a timeout fires caller asyncio.timeout inner coroutine async with timeout(0.05) deadline: cancel the task sees CancelledError, cleans up CancelledError propagates TimeoutError (context: CancelledError) Tested on Python 3.14; expired() is True when the timeout caused it.

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.

The exception classes involved, tested A grid of 4 rows by 3 columns. The exception classes involved, tested relationship Python 3.14 Python 3.10 asyncio.TimeoutError is TimeoutError True False concurrent.futures.TimeoutError is TimeoutError True False CancelledError subclass of Exception False False except Exception catches a wait_for timeout yes (TimeoutError) yes Catch asyncio.TimeoutError if you still support 3.10.

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.

How should this code react? A decision on Where is this code relative to the timeout with 4 outcomes. How should this code react? Where is this code relative to the timeout? inside the timed-out call CancelledError: clean up, re-raise same as any cancel where the timeout was set TimeoutError: fallback or 504 our deadline boundary sees CancelledError re-raise caller gave up metrics count separately slow dependency vs impatient caller Inside, it is always cancellation; outside, the timeout tells you which.

Verification

Timeouts and cancellations are handled correctly when:

  • Timed-out code treats CancelledError as "stop" and re-raises after cleanup.
  • Boundaries catch TimeoutError for their own deadlines and re-raise CancelledError.
  • The right class is caught on every supported Python version.
  • No broad except swallows 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 TimeoutError inside the timed-out code. It sees CancelledError instead.
  • except TimeoutError on 3.10. It misses asyncio.TimeoutError.
  • except BaseException without 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.