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:
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
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 withSocketTimeoutError: Timeout on reading data from socket.The mechanism is entirely client-side — no server-side disconnect is required:
A request completes. When the response body reaches EOF,
StreamReader.feed_eof()runs theon_eofcallbacks, which includeResponseHandler._drop_timeout(cancels thesock_readtimer) andClientResponse._response_eof(releases the connection into the keep-alive pool). At this point the pooled connection correctly carries no read-timeout timer.The application then reads the already-buffered body (
await resp.read()/.json()/.text()).StreamReader.read()→readany()→_read_nowait_chunk()callsself._protocol.resume_reading(). In 3.14 this call lost itsself._protocol._reading_pausedguard, so it now runs even when the transport was never paused.ResponseHandler.resume_reading()callsself._reschedule_timeout(), which re-armsloop.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 livesock_readtimer.After
sock_readseconds of idle,_on_read_timeoutfires and callsset_exception(SocketTimeoutError(...))on the pooledResponseHandler. It does not close the transport — it only stamps the exception and sets_should_close = True.BaseConnector._getreuses the connection anyway: it checks onlyproto.is_connected()(transport still open) and the keep-alive age — it does not look atshould_closeor the stored exception.The next request calls
await protocol.read()→DataQueue.read()→raise self._exception, raising the staleSocketTimeoutErrorimmediately (sub-millisecond), long before any realsock_readwindow.The regression bisects to PR #11966 ("Allow decompression to continue after exceeding max_length"), which removed the
_reading_pausedguard aroundresume_reading()inStreamReader.feed_eofandStreamReader._read_nowait_chunk. BecauseResponseHandler.resume_reading()also reschedules the read timeout, reading a fully-buffered body now re-armssock_readon 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 ownsock_readtimer.The practical exposure is worst when
keepalive_timeout > sock_read: the connection is poisoned att = sock_readbut not evicted by_cleanupuntilt = 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.
Output on 3.14.1 (and on
master):Output on 3.13.5 (unaffected):
Expected behavior
A connection sitting idle in the keep-alive pool must not carry an armed
sock_readtimer; thesock_readtimeout 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
(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
(Also reproduced/observed on Python 3.14. The defect is Python-version-independent.)
aiohttp Version
Reproduced on 3.14.1 with the C extension active (
aiohttp.http_parser.HttpRequestParser.__module__ == "aiohttp._http_parser") and onmaster(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
totalinstead ofsock_read(the total timeout lives on the task, not on the pooled connection, so it cannot poison it); or setkeepalive_timeout < sock_readso_cleanupevicts the idle connection before the timer fires.I agree to follow the aio-libs Code of Conduct