Skip to content

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

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.

Time a trickling client held one 40-byte read 2 horizontal bars comparing 1 s timeout per read() with the others. Time a trickling client held one 40-byte read 1 s timeout per read() 19.5 s, completed 3 s deadline per message 3.0 s, TimeoutError Python 3.14; client sent 1 byte every 0.5 s. A per-read timeout bounds gaps; only a deadline bounds the message.

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.

Reading one message with two timeouts A flow of 4 stages. Reading one message with two timeouts wait for header idle timeout check length reject oversize read body frame deadline timeout? close the connection Separate budgets for "nothing happening" and "message in progress".

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.

Which timeout does this read or write need? A decision on What is the code waiting for with 4 outcomes. Which timeout does this read or write need? What is the code waiting for? the next message to start idle timeout minutes the rest of a message per-message deadline seconds drain() to a slow peer write deadline + abort seconds a whole client call one deadline around it caller's budget Every await on the network needs a bound, and the bound belongs to a unit of work.

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; use abort().
  • 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.