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()andasyncio.timeout_at(); both return aTimeoutobject. - Timeout semantics from Timeouts & Deadlines — a timeout cancels its block and converts the cancellation into
TimeoutErrorat its own boundary. - The loop clock. All deadlines here are absolute values on
loop.time(), a monotonic clock unaffected by system time changes.
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.
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.
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.
Verification¶
Dynamic deadlines are used correctly when:
- Deadlines are absolute: every
reschedule()argument isloop.time() + norNone. - 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 aTimeoutErroris 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 theasync 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 ofloop.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.
Related¶
- Timeouts & Deadlines — up to the topic overview.
- Per-attempt and total timeouts for retries — the two-deadline structure a retry loop needs.
- Propagating deadlines with contextvars — carrying an absolute deadline down a call stack.
- Resilience, Cancellation & Error Handling — the section overview.