Skip to content

sock_read timeout is re-armed on a keep-alive connection after it returns to the pool, poisoning it (regression in 3.14) #12953

Description

@daragok

Describe the bug

When ClientTimeout(sock_read=...) is used together with the keep-alive connection pool, a connection can be poisoned while sitting idle in the pool and then handed to a later request, which fails immediately with SocketTimeoutError: Timeout on reading data from socket.

The mechanism is entirely client-side — no server-side disconnect is required:

  1. A request completes. When the response body reaches EOF, StreamReader.feed_eof() runs the on_eof callbacks, which include ResponseHandler._drop_timeout (cancels the sock_read timer) and ClientResponse._response_eof (releases the connection into the keep-alive pool). At this point the pooled connection correctly carries no read-timeout timer.

  2. The application then reads the already-buffered body (await resp.read() / .json() / .text()). StreamReader.read()readany()_read_nowait_chunk() calls self._protocol.resume_reading(). In 3.14 this call lost its self._protocol._reading_paused guard, so it now runs even when the transport was never paused.

  3. ResponseHandler.resume_reading() calls self._reschedule_timeout(), which re-arms loop.call_later(sock_read, self._on_read_timeout) — but the connection is already in the pool (step 1). The idle pooled connection now has a live sock_read timer.

  4. After sock_read seconds of idle, _on_read_timeout fires and calls set_exception(SocketTimeoutError(...)) on the pooled ResponseHandler. It does not close the transport — it only stamps the exception and sets _should_close = True.

  5. BaseConnector._get reuses the connection anyway: it checks only proto.is_connected() (transport still open) and the keep-alive age — it does not look at should_close or the stored exception.

  6. The next request calls await protocol.read()DataQueue.read()raise self._exception, raising the stale SocketTimeoutError immediately (sub-millisecond), long before any real sock_read window.

The regression bisects to PR #11966 ("Allow decompression to continue after exceeding max_length"), which removed the _reading_paused guard around resume_reading() in StreamReader.feed_eof and StreamReader._read_nowait_chunk. Because ResponseHandler.resume_reading() also reschedules the read timeout, reading a fully-buffered body now re-arms sock_read on a connection that has already been pooled.

This is distinct from #12795 / #12798 (which was a cancellation mid-parse leaving a desynchronized connection, fixed by closing the transport in data_received). This one needs no cancellation and no server disconnect — the client poisons its own idle pooled connection via its own sock_read timer.

The practical exposure is worst when keepalive_timeout > sock_read: the connection is poisoned at t = sock_read but not evicted by _cleanup until t = keepalive_timeout, so every connection that goes idle is a landmine for that whole window.

To Reproduce

Self-contained, no server-side disconnect. The client poisons its own pooled connection.

import asyncio
import aiohttp
from aiohttp import web


async def handler(request):
    return web.json_response({"ok": True})


async def main():
    app = web.Application()
    app.router.add_get("/", handler)
    runner = web.AppRunner(app)
    await runner.setup()
    await web.TCPSite(runner, "127.0.0.1", 8080).start()

    timeout = aiohttp.ClientTimeout(total=30, sock_read=1)   # sock_read set
    conn = aiohttp.TCPConnector(keepalive_timeout=75)        # keepalive > sock_read
    async with aiohttp.ClientSession(timeout=timeout, connector=conn) as s:
        # request 1: reading the buffered body re-arms sock_read on the POOLED conn
        async with s.get("http://127.0.0.1:8080/") as r:
            await r.read()

        proto = next(iter(conn._conns.values()))[0][0]
        print("after req1: read_timeout_handle armed =",
              proto._read_timeout_handle is not None)

        # idle longer than sock_read but shorter than keepalive_timeout
        await asyncio.sleep(2)

        print("after idle: protocol exception =",
              type(proto.exception()).__name__ if proto.exception() else None)

        # request 2: reuses the now-poisoned pooled connection
        async with s.get("http://127.0.0.1:8080/") as r:
            await r.read()
        print("RESULT: OK")

    await runner.cleanup()


asyncio.run(main())

Output on 3.14.1 (and on master):

after req1: read_timeout_handle armed = True
after idle: protocol exception = SocketTimeoutError
Traceback (most recent call last):
  ...
aiohttp.client_exceptions.SocketTimeoutError: Timeout on reading data from socket

Output on 3.13.5 (unaffected):

after req1: read_timeout_handle armed = False
after idle: protocol exception = None
RESULT: OK

Expected behavior

A connection sitting idle in the keep-alive pool must not carry an armed sock_read timer; the sock_read timeout should apply only while a read on an in-flight request is actually pending. Reading the buffered body of an already-completed response must not re-arm a timeout on the pooled connection. (This was the behavior in 3.13.5.)

Logs/tracebacks

Traceback (most recent call last):
  File ".../aiohttp/client.py", line 834, in _connect_and_send_request
    await resp.start(conn)
  File ".../aiohttp/client_reqrep.py", line 558, in start
    message, payload = await protocol.read()
  File ".../aiohttp/streams.py", line 713, in read
    raise self._exception
aiohttp.client_exceptions.SocketTimeoutError: Timeout on reading data from socket

(Note the failure is raised in ~1 ms, not after the configured sock_read — the exception was stamped on the connection earlier, while it was idle in the pool.)

Python Version

$ python --version
Python 3.13.9

(Also reproduced/observed on Python 3.14. The defect is Python-version-independent.)

aiohttp Version

$ python -m pip show aiohttp
Name: aiohttp
Version: 3.14.1

Reproduced on 3.14.1 with the C extension active (aiohttp.http_parser.HttpRequestParser.__module__ == "aiohttp._http_parser") and on master (4.0.0a2.dev0). Not reproducible on 3.13.5.

OS

macOS (also observed on Debian in production).

Related component

Client

Additional context

  • Workarounds: rely on total instead of sock_read (the total timeout lives on the task, not on the pooled connection, so it cannot poison it); or set keepalive_timeout < sock_read so _cleanup evicts the idle connection before the timer fires.

  • I agree to follow the aio-libs Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions