Back to Blog
Python

Python asyncio timeout: wait_for, timeout, and cancellation

python asyncio timeout: Apply timeouts to asyncio coroutines with wait_for, the timeout context manager, and timeout_at, and understand cancellation and cleanup behavior.

asynciotimeoutcancellationconcurrencyevent loop
An illustration of an asyncio event loop with a timer that cancels a running coroutine when a deadline passes.

Running a coroutine with asyncio.run() or asyncio.gather() imposes no limit on how long it may execute. A stalled database connection, a hung socket, or a slow third-party API can hold the event loop indefinitely. Python's asyncio module offers two main mechanisms for applying a python asyncio timeout: the asyncio.wait_for() function and the asyncio.timeout() context manager introduced in Python 3.11. Both cancel the underlying task when the deadline passes, but they differ in how they are used and how errors propagate.

Applying a Timeout with asyncio.wait_for

asyncio.wait_for(awaitable, timeout) is the original timeout API. It awaits a single awaitable and raises asyncio.TimeoutError if the awaitable does not complete within the given number of seconds.

import asyncio async def fetch_data(): await asyncio.sleep(10) return {"status": "ok"} async def main(): try: result = await asyncio.wait_for(fetch_data(), timeout=2.0) print(result) except asyncio.TimeoutError: print("fetch_data did not complete in time") asyncio.run(main())

When the timeout expires, wait_for cancels the inner task and raises asyncio.TimeoutError at the await point. The coroutine fetch_data receives a CancelledError and is unwound. If fetch_data catches CancelledError and suppresses it, wait_for waits for it to finish before raising the timeout error. A coroutine that swallows cancellation can therefore delay the timeout indefinitely.

Using the asyncio.timeout Context Manager

Python 3.11 introduced asyncio.timeout(), an async context manager that applies a timeout to the block it wraps. This is often cleaner than wait_for when the timeout should cover multiple operations or when the surrounding code already uses async with.

import asyncio async def main(): try: async with asyncio.timeout(2.0): data = await fetch_data() processed = await process_data(data) except TimeoutError: print("the block did not finish in time")

asyncio.timeout() raises the built-in TimeoutError, which since Python 3.11 is the same exception as asyncio.TimeoutError. The context manager measures the time spent inside the block, not the duration of a single awaitable, so it naturally covers sequential operations.

The context manager returns an asyncio.Timeout object whose reschedule() method can extend or shorten the deadline while the block is running:

async def main(): timeout = asyncio.timeout(1.0) async with timeout: await first_operation() timeout.reschedule(5.0) await second_operation()

TimeoutError, CancelledError, and Cancellation Semantics

When a timeout fires, two exceptions are involved. The task being awaited receives asyncio.CancelledError, which unwinds the coroutine. The await expression that triggered the timeout raises TimeoutError (or asyncio.TimeoutError). These are distinct exceptions with different purposes.

CancelledError inherits from BaseException in Python 3.8 and later, not from Exception. This means a bare except Exception block will not catch it, which is intentional: cancellation should propagate unless the coroutine explicitly handles it. A coroutine that needs to clean up resources on cancellation should catch CancelledError, perform cleanup, and re-raise it.

async def operation_with_cleanup(): try: await long_running_work() except asyncio.CancelledError: await release_resources() raise

If the coroutine does not re-raise CancelledError, the cancellation is considered handled, and wait_for or timeout will not raise TimeoutError until the coroutine actually returns. This can make the timeout appear to hang.

What Happens to the Cancelled Task

When a timeout cancels a task, the cancellation is delivered as CancelledError at the current await point inside the coroutine. If the coroutine is blocked on a sleep, a socket read, or another awaitable, that awaitable is interrupted. For I/O operations, the underlying resource is typically closed by the event loop, but the exact behavior depends on the transport or protocol implementation.

A common mistake is to assume that cancellation stops the underlying operation immediately. For example, cancelling a task that wraps an HTTP request does not necessarily close the TCP connection synchronously; the connection may be returned to a pool or closed asynchronously. If you need deterministic cleanup, handle it explicitly in the coroutine's finally block or in the CancelledError handler.

Nested Timeouts and Absolute Deadlines with timeout_at

Timeouts can be nested. When an inner timeout fires, it cancels its own scope and raises TimeoutError; the outer timeout continues to measure its own deadline. This is useful for bounding individual operations within a larger overall budget.

async def main(): try: async with asyncio.timeout(10.0): async with asyncio.timeout(2.0): await fetch_data() await process_data() except TimeoutError: print("a nested timeout fired")

For an absolute deadline, use asyncio.timeout_at(when), where when is a monotonic clock value from loop.time(). This is useful when a total budget must be shared across multiple operations or when the deadline is computed once and should not shift.

import asyncio async def main(): loop = asyncio.get_running_loop() deadline = loop.time() + 5.0 try: async with asyncio.timeout_at(deadline): await operation_one() await operation_two() except TimeoutError: print("absolute deadline exceeded")

Protecting Work from Cancellation with shield

Sometimes you want the caller to stop waiting after a timeout, but you still want the underlying operation to continue. asyncio.shield() protects a task from cancellation: when the surrounding scope is cancelled, the shield raises CancelledError at the await point, but the protected task keeps running.

async def main(): task = asyncio.create_task(fetch_data()) try: async with asyncio.timeout(2.0): result = await asyncio.shield(task) except TimeoutError: print("gave up waiting; task continues in background") # task is still running here

This is useful for background cleanup, logging, or work that must complete even if the caller stops waiting. Note that if the task itself is cancelled directly, the shield does not prevent that; it only protects against cancellation of the surrounding scope.

Cleanup, Resource Handling, and Production Considerations

In production, timeouts are a guard against resource leaks and unbounded latency, but they are not a substitute for proper cleanup. When a timeout fires, the coroutine is cancelled, and any resources it holds — sockets, file handles, database connections — must be released by the coroutine itself.

A finally block in the coroutine is the most reliable place for cleanup, because it runs both on normal completion and on cancellation:

async def operation_with_cleanup(): conn = await open_connection() try: return await conn.query() finally: await conn.close()

When a timeout fires, the coroutine's finally blocks and CancelledError handlers run as part of the cancellation process. The caller's TimeoutError is raised only after the cancellation unwinds. If cleanup is slow, the timeout error is delayed accordingly. For cleanup that must not delay the caller, run it in a separate task or shield it from cancellation.

Another production concern is the interaction between timeouts and asyncio.gather(). If one task in a gather times out and is cancelled, the other tasks continue running unless you cancel them explicitly. Use return_exceptions=True or wrap each task with its own timeout to control the behavior precisely.

python asyncio timeout: Practical Usage and Code Examples | RYUSLOG DEV