Mastering Nested asyncio.timeout in Python: How to Tell Which Timeout Actually Expired
The Problem with Nested asyncio Timeouts
Python 3.11 introduced the clean, context-manager-based asyncio.timeout() (and asyncio.timeout_at()) to replace the older, often clunky asyncio.wait_for(). A common design pattern in resilient networking is pairing an overall operation budget with per-attempt timeouts inside a retry loop.
However, when nesting an inner timeout inside an outer timeout, you might encounter an edge case where both inner.expired() and outer.expired() return True simultaneously. Consider this scenario:
import asyncio
async def fetch(budget, per_call):
try:
async with asyncio.timeout(budget) as outer:
for i in range(3):
try:
async with asyncio.timeout(per_call) as inner:
await asyncio.sleep(0.3)
except TimeoutError:
print(f"Attempt {i}: inner={inner.expired()}, outer={outer.expired()}")
except TimeoutError:
print(f"Budget exceeded: inner={inner.expired()}, outer={outer.expired()}")
asyncio.run(fetch(0.2, 0.1))In the final iteration, if the deadline coincides or falls within the same event loop tick, both timeouts report having expired. If you track telemetry or metric logs for both categories (attempt failure vs. complete request failure), this results in duplicate or misleading alerts.
Why Does This Happen?
Under the hood, asyncio.Timeout.expired() does not signify "this context manager raised the exception that is currently propagating". Instead, it simply checks whether the current loop time is greater than or equal to the scheduled deadline when evaluated (loop.time() >= self._deadline), provided that the timeout was cancelled.
When the outer budget expires, it cancels the running task. Because the task was currently suspended inside the inner block, the inner block's deadline has also passed relative to the loop's clock. Both context managers notice their deadline is in the past, causing both .expired() checks to evaluate to True.
How to Accurately Determine Which Timeout Raised
To accurately distinguish between an inner timeout and an outer budget exhaustion, you have two primary strategies: checking exception scope or clamping deadlines.
Strategy 1: Scope-Based Error Classification (Recommended)
Because asyncio.Timeout re-raises a TimeoutError if its deadline was met, the cleanest way to differentiate errors is to handle them strictly where they are caught.
If the inner block catches TimeoutError, verify whether the outer timeout is also triggered before assuming it was purely a per-call timeout:
import asyncio
async def fetch(budget, per_call):
try:
async with asyncio.timeout(budget) as outer:
for i in range(3):
try:
async with asyncio.timeout(per_call) as inner:
await asyncio.sleep(0.3)
except TimeoutError:
# If outer has also expired, this is actually the outer budget firing
if outer.expired():
raise # Re-raise to let the outer block handle it
print(f"Per-call timeout on attempt {i}")
except TimeoutError:
print("Overall budget exhausted")By prioritizing the check of outer.expired() inside the inner except TimeoutError: block and re-raising, you avoid falsely logging a per-call timeout when the overall deadline was responsible for terminating the task.
Strategy 2: Clamping Deadlines with asyncio.timeout_at
Another robust approach is to clamp the inner deadline to the outer deadline using asyncio.timeout_at(). This guarantees that an inner call will never plan to run past the end of the total budget.
import asyncio
async def fetch(budget, per_call):
loop = asyncio.get_running_loop()
budget_deadline = loop.time() + budget
try:
async with asyncio.timeout_at(budget_deadline) as outer:
for i in range(3):
now = loop.time()
# Clamp the per-call deadline so it never exceeds the overall budget
call_deadline = min(now + per_call, budget_deadline)
try:
async with asyncio.timeout_at(call_deadline) as inner:
await asyncio.sleep(0.3)
except TimeoutError:
# If the call deadline matched the budget deadline, it was the outer timeout
if call_deadline >= budget_deadline:
raise
print(f"Attempt {i} timed out")
except TimeoutError:
print("Overall budget exceeded")Summary
expired()means elapsed, not raised: A value ofTrueindicates the deadline passed, not necessarily that this specific context manager triggered the cancellation.- Check the outer context first: In nested blocks, test
outer.expired()inside the inner exception handler. If it returnsTrue, re-raise the exception to allow the outer context to consume it. - Clamp your deadlines: Using
asyncio.timeout_at()allows precise control over per-call budgets so inner deadlines never overshoot total remaining time.