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 of True indicates 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 returns True, 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.