Adding Read Timeouts to asyncio Streams¶
asyncio.StreamReader has no timeout of its own: await reader.readexactly(40) waits forever if the peer sends nothing, and that is exactly how idle or malicious clients pin connections and memory. The fix is to wrap reads in asyncio.timeout — but which reads matters. Tested on Python 3.14 with a client that trickled one byte every 0.5 s: a server that put a 1 s timeout on each read() call never timed out, because every call received a byte in time, and the 40-byte message took 19.5 s to arrive. A server that put a 3 s deadline on the whole message stopped the client at 3.0 s. A timed-out readexactly does not lose data either: after a timeout with 6 of 10 bytes received, those 6 bytes were still in the buffer and a retried readexactly(10) returned the complete message. This guide places timeouts on the right units and handles what happens after one fires.
Prerequisites¶
- Python 3.11+ for
asyncio.timeout; stdlib only. - A stream server, from writing a TCP server with asyncio.start_server.
- Timeout tools, from choosing asyncio.timeout vs wait_for.
1. Put a deadline on each message, not each read¶
A per-read timeout bounds the gap between bytes. A per-message deadline bounds how long one message may take. Only the second stops a slow sender:
# Weak: each read() returns within 1 s, so a trickling client is never stopped
buf = b""
while len(buf) < 40:
buf += await asyncio.wait_for(reader.read(40 - len(buf)), timeout=1.0) # measured: 19.5 s total
# Strong: the whole frame must arrive within 3 s
async with asyncio.timeout(3.0):
frame = await reader.readexactly(40) # measured: TimeoutError at 3.0 s
The trickling client is the "slowloris" pattern: it keeps a connection and a handler busy for as long as it likes while sending almost nothing. With a per-message deadline, the maximum time any one message can hold a handler is fixed regardless of how the bytes are spread out. Choose the deadline from the largest legitimate message and the slowest legitimate network: a small control frame should arrive in a second or two; a large upload needs a proportionally longer budget or a minimum-throughput rule.
Verify: a test client sending one byte per half second is disconnected after the message deadline, not after it finishes.
2. Separate idle time from message time¶
Between messages, a connection may legitimately be idle for a long time; once a message starts, it should finish quickly. Use two timeouts:
IDLE_TIMEOUT = 300.0 # waiting for the next message to begin
FRAME_TIMEOUT = 5.0 # finishing a message once its first byte arrived
async def read_message(reader: asyncio.StreamReader) -> bytes | None:
try:
async with asyncio.timeout(IDLE_TIMEOUT):
header = await reader.readexactly(4) # first bytes of the next message
except asyncio.IncompleteReadError:
return None # client closed between messages
length = int.from_bytes(header, "big")
if length > MAX_FRAME:
raise ValueError("frame too large")
async with asyncio.timeout(FRAME_TIMEOUT):
return await reader.readexactly(length) # the rest, promptly
The idle timeout reclaims connections whose clients disappeared without closing, or that keep connections open "just in case"; the frame timeout stops slow senders. For keep-alive HTTP-like protocols, the idle timeout is the server's keep-alive setting — which must be longer than clients' own, as in handling stale pooled connections after idle timeouts.
Verify: an idle client is disconnected after IDLE_TIMEOUT, and one that stalls mid-message after FRAME_TIMEOUT.
3. Know what is left in the buffer after a timeout¶
When a timeout cancels readexactly or readuntil, the bytes received so far are not consumed — they stay in the reader's buffer:
try:
async with asyncio.timeout(0.3):
data = await reader.readexactly(10)
except TimeoutError:
pass # measured: 6 of 10 bytes had arrived and remain buffered
data = await reader.readexactly(10) # retry returned the complete b"0123456789"
That makes a retry possible for readexactly and readuntil. It does not apply to code that assembles a message from several reads, like the per-read loop in step 1: bytes already moved into your own buffer are yours to keep or discard. In practice, a timed-out read on a server usually means the connection should be closed rather than retried — the peer is too slow or gone, and the protocol state after a partial message is rarely worth recovering. Retrying makes sense on clients with application-level keepalives, where a timeout means "send a ping and wait again".
Verify: after a timeout, either the connection is closed or the next read starts at a known protocol position; never parse from the middle of a frame.
4. Time out writes too¶
A peer that stops reading makes await writer.drain() wait forever, once buffers are full. Put a deadline on sending a response:
async def send(writer: asyncio.StreamWriter, payload: bytes, timeout: float = 10.0) -> None:
writer.write(payload)
try:
async with asyncio.timeout(timeout):
await writer.drain()
except TimeoutError:
writer.transport.abort() # drop the connection without flushing
raise
transport.abort() closes immediately and discards buffered data, which is right for a peer that is not reading — a graceful close() would wait to flush to a reader that never reads. Without a write deadline, one stuck client holds its handler, its buffered response, and a connection slot indefinitely.
Verify: a test client that connects, sends a request and never reads the response is aborted after the write timeout, and the server's memory returns to baseline.
5. Apply deadlines on the client side as well¶
Clients face the same problem in reverse: a server that accepts the connection and then never answers. Wrap the whole exchange:
async def call(host: str, port: int, request: bytes, deadline: float = 2.0) -> bytes:
async with asyncio.timeout(deadline): # connect + send + receive
reader, writer = await asyncio.open_connection(host, port)
try:
writer.write(len(request).to_bytes(4, "big") + request)
await writer.drain()
length = int.from_bytes(await reader.readexactly(4), "big")
return await reader.readexactly(length)
finally:
writer.close()
One asyncio.timeout around connect, send and receive gives the caller a single, honest deadline, and its cancellation propagates into whichever await is pending. When the deadline comes from an upstream request, pass the remaining budget instead of a fixed number, as in propagating deadlines across async service calls.
Verify: calling a test server that accepts and never responds raises TimeoutError at the deadline, and no socket is left open.
Verification¶
Stream timeouts are in place when:
- Every message has a deadline, separate from the idle timeout between messages.
- Slow senders are cut off at the message deadline, not after finishing.
- Writes have deadlines and abort stuck peers.
- Client calls carry one deadline covering connect, send and receive.
Diagnostic Hook: count timeouts by kind — idle, frame, write — per client address. Idle timeouts are normal housekeeping; frame timeouts concentrated on a few addresses are slow or hostile clients; write timeouts mean clients that stop reading, often crashed processes behind a live TCP connection.
Pitfalls & edge cases¶
- Per-read timeouts. Measured: a trickling client held a 40-byte read for 19.5 s.
- No idle timeout. Abandoned connections accumulate until file descriptors run out.
close()on a peer that never reads. It waits to flush; useabort().- Parsing after a partial read. Close the connection unless the protocol can resynchronize.
Frequently Asked Questions¶
How do I add a timeout to asyncio StreamReader reads?
Wrap the read in async with asyncio.timeout(seconds) or asyncio.wait_for. StreamReader has no built-in timeout, so without one a read waits forever.
Why doesn't my read timeout stop slow clients?
A timeout on each read() only bounds the gap between bytes. A client sending one byte every 0.5 s defeated a 1 s per-read timeout for 19.5 s; a 3 s deadline on the whole message stopped it at 3.0 s.
Is data lost when readexactly times out?
No. The bytes received so far stay in the reader's buffer; in testing, 6 of 10 bytes remained after a timeout and a retried readexactly returned the full message.
How do I time out a write to a slow client?
Wrap await writer.drain() in asyncio.timeout and call writer.transport.abort() on timeout, which drops the connection without waiting to flush.
Related¶
- Streams, Transports & Protocols — up to the topic overview.
- Building a minimal HTTP server on asyncio streams — the same deadlines applied to HTTP request heads.
- Network I/O & Protocol Handling — the section overview.