Skip to content

Rescheduling asyncio.timeout Deadlines Dynamically

A fixed timeout answers one question: has this operation taken too long overall? For a request-response call that is the right question. For a large download, a streaming response, a long-running job that reports progress or a WebSocket connection, it is the wrong one — a 30-second cap fails a healthy 10-minute transfer, and a 10-minute cap tolerates a peer that went silent after the first byte. What those operations need is an idle deadline: fail when nothing has happened recently, not when a lot has happened over a long time. asyncio.timeout() supports this directly, because the object it yields is live and its deadline can be moved while the block is running.

Prerequisites

  • Python 3.11+ for asyncio.timeout() and asyncio.timeout_at(); both return a Timeout object.
  • Timeout semantics from Timeouts & Deadlines — a timeout cancels its block and converts the cancellation into TimeoutError at its own boundary.
  • The loop clock. All deadlines here are absolute values on loop.time(), a monotonic clock unaffected by system time changes.
The Timeout object returned by asyncio.timeout() A grid of 4 rows by 2 columns. The Timeout object returned by asyncio.timeout() call does use it when cm.when() returns the absolute deadline, or None you need the remaining time cm.reschedule(loop.time() + n) moves the deadline progress was made cm.reschedule(None) removes the deadline an uninterruptible section starts cm.expired() True if this block timed out distinguishing your timeout from others Deadlines are absolute values on loop.time(), never durations.

1. Move a deadline while the block runs

async with asyncio.timeout(0.3) as cm binds cm, and cm.when() returns the absolute deadline. cm.reschedule() replaces it:

loop = asyncio.get_running_loop()
async with asyncio.timeout(0.3) as cm:
    print(cm.when() - loop.time())                     # 0.3
    await do_some_work()
    cm.reschedule(loop.time() + 0.5)                   # progress: give it longer
    print(cm.when() - loop.time())                     # 0.5
    await do_more_work()

That block completed at 0.6 s under an original deadline of 0.3 s, because the reschedule moved the deadline before the first one arrived. reschedule() takes an absolute time — loop.time() + n, never n — which is the same convention as loop.call_at() and asyncio.timeout_at().

Rescheduling is only valid while the block is live. Afterwards:

RuntimeError: Cannot change state of expired Timeout

Verify: cm.when() reflects the new value, and the block survives past its original deadline.

2. Build an idle timeout

The canonical use is a loop that resets the deadline every time something arrives, so the timeout measures silence rather than duration:

async def read_with_idle_timeout(source, idle: float) -> int:
    loop = asyncio.get_running_loop()
    received = 0
    async with asyncio.timeout(idle) as cm:
        async for chunk in source:
            received += 1
            cm.reschedule(loop.time() + idle)          # each chunk resets the clock
            await handle(chunk)
    return received

Ten chunks arriving 50 ms apart under a 200 ms idle timeout completed all ten in 0.50 s — five times the nominal timeout, correctly, because the stream never went quiet. The same stream with 300 ms gaps raised TimeoutError after 0.20 s, at the first gap longer than the window.

This is what HTTP clients mean by a read timeout, and what httpx and aiohttp implement internally. Writing it yourself is worthwhile whenever you consume a stream those clients do not own — a WebSocket, a database cursor, a subprocess's output, an SSE stream.

Two refinements matter in production. Reschedule before handling the chunk, not after, so slow local processing does not eat the peer's allowance. And when the protocol has heartbeats, let a heartbeat reset the deadline too — otherwise an idle-but-healthy connection is torn down.

Verify: the read survives many more than one idle window when chunks keep arriving, and fails one idle window after the last one.

An idle timeout against a stalled stream 2 lanes over time. An idle timeout against a stalled stream chunks idle deadline pushed out on each chunk nothing arrives TimeoutError time → Measured: chunks 50 ms apart ran to completion under a 200 ms idle timeout.

3. Suspend the deadline for uninterruptible work

cm.reschedule(None) removes the deadline entirely, and when() then returns None. That is the tool for a section that must not be cancelled halfway — committing a transaction, releasing a lease, flushing a final write:

async with asyncio.timeout(30) as cm:
    data = await fetch_everything()                    # bounded
    cm.reschedule(None)                                # commit must not be interrupted
    await commit(data)
    cm.reschedule(loop.time() + 5)                     # bounded again for the rest
    await notify_downstream()

A disabled deadline survived a sleep twice its original length and was restored afterwards without complaint. Use it sparingly and in small scopes: a deadline that is off is a hang waiting to happen, and the enclosing cancellation is often a better tool — see shielding critical sections for the alternative that protects against outer cancellation rather than this block's own deadline.

Verify: with the deadline disabled, cm.when() is None and long work completes; after restoring, the timeout fires again.

4. Share one deadline across stages with timeout_at

Nested relative timeouts compose badly: three stages each given "five seconds" can take fifteen. An absolute deadline composes correctly, because every stage measures against the same instant:

deadline = loop.time() + request_budget
async with asyncio.timeout_at(deadline):
    user = await load_user(uid)
    async with asyncio.timeout_at(deadline):           # same instant, not a new allowance
        return await render(user)

A nested pair on one deadline fired once, at 0.30 s, regardless of the inner sleep asking for a full second. The inner block simply cannot outlive the outer one, which is what you want for a request budget.

Because the value is a plain float, it travels easily — in a contextvar, as described in propagating deadlines with contextvars, or over the wire as a gRPC deadline. Helper functions can then read the ambient deadline and apply timeout_at without taking a timeout parameter at all.

Verify: the elapsed time for the nested pair equals the outer budget, not the sum of the inner ones.

Relative timeouts or one shared deadline? 2 columns contrasting nested asyncio.timeout(n), asyncio.timeout_at(deadline). Relative timeouts or one shared deadline? nested asyncio.timeout(n) each stage gets n seconds durations add up the innermost wins only by luck total is hard to reason about fine for one isolated call asyncio.timeout_at(deadline) one absolute instant stages share the remaining time the outermost always wins trivially propagated to callees the request ends when it must Measured: nested timeout_at on one deadline fired once, at 0.30 s, whatever the inner sleep asked for.

5. Tell your timeout from someone else's

Inside a nested structure, TimeoutError may come from your block or from one further out. cm.expired() answers precisely:

try:
    async with asyncio.timeout(idle) as cm:
        await read_everything(source)
except TimeoutError:
    if cm.expired():
        raise StreamStalled(f"no data for {idle}s") from None
    raise                                              # someone else's deadline; let it pass

This distinction matters when converting to a domain error. Reporting "no data for 30s" because an outer request budget expired sends the next reader to the wrong place entirely. The same reasoning applies to asyncio.CancelledError: check whether your scope caused it before interpreting it, as covered in cancellation patterns.

Verify: an outer timeout firing during the inner block leaves cm.expired() False.

Which deadline does this operation need? A decision on What does slow mean here with 3 outcomes. Which deadline does this operation need? What does slow mean here? the whole call is too long a fixed total asyncio.timeout(total) the peer went quiet an idle deadline reschedule on every chunk one item is stuck a per-item deadline a timeout inside the loop A long download needs an idle deadline; a request-response call needs a total.

Verification

Dynamic deadlines are used correctly when:

  • Deadlines are absolute: every reschedule() argument is loop.time() + n or None.
  • Idle timeouts reset on progress, before handling the item rather than after.
  • Heartbeats count as progress where the protocol has them.
  • Suspensions are narrow: reschedule(None) covers only the uninterruptible operation and is restored immediately.
  • Errors are attributed: cm.expired() decides whether a TimeoutError is yours before you convert it.

Pitfalls & edge cases

  • Passing a duration to reschedule(). cm.reschedule(5) sets the deadline to 5 seconds after the loop started — long past, so the block is cancelled at once.
  • Rescheduling after the block. Raises RuntimeError: Cannot change state of expired Timeout; keep changes inside the async with.
  • Rescheduling from another task. The object is not designed for cross-task mutation; move the deadline from the task that owns the block.
  • Idle timeouts with no total. A peer that sends one byte per second forever keeps the connection alive indefinitely; combine an idle deadline with a generous total.
  • time.monotonic() instead of loop.time(). They are usually the same source, but the loop's clock is the one the timeout compares against; use it.
  • Rescheduling in a tight loop. Calling reschedule() per byte is measurable overhead; do it per chunk or per message.

Frequently Asked Questions

How do I extend an asyncio timeout while it is running?

Bind the context manager — async with asyncio.timeout(n) as cm — and call cm.reschedule(loop.time() + n) inside the block. The argument is an absolute time on the loop's monotonic clock, not a duration, and cm.when() returns the current deadline.

How do I implement an idle timeout in asyncio?

Open asyncio.timeout(idle) around the consuming loop and call cm.reschedule(loop.time() + idle) each time data arrives. The deadline then measures silence rather than total duration: ten chunks 50 ms apart completed under a 200 ms idle timeout, while a 300 ms gap raised TimeoutError.

Can I disable an asyncio timeout temporarily?

Yes — cm.reschedule(None) removes the deadline, and when() then returns None. Use it for a short uninterruptible section such as a commit, and restore a deadline immediately afterwards, because a disabled deadline is an unbounded wait.

What is the difference between asyncio.timeout and asyncio.timeout_at?

timeout() takes a duration and computes the deadline from the current loop time; timeout_at() takes the absolute deadline directly. Use timeout_at when several stages must share one request budget — nested blocks on the same deadline fire once, at that instant, rather than granting each stage a fresh allowance.

How do I know whether a TimeoutError came from my own timeout block?

Call cm.expired() in the except clause. It is True only when that block's deadline was the one that fired, so you can convert your own timeout into a domain-specific error and re-raise anyone else's unchanged.