Describe the bug
Since aiohttp 3.14.0, a client that consumes a large Content-Length response slowly
(e.g. application-level bandwidth throttling with asyncio.sleep between
iter_chunked() reads) fails with a contradictory error when the server sends the
whole body quickly and then half-closes the connection (as Amazon S3 does):
aiohttp.client_exceptions.ClientPayloadError: Response payload is not completed:
<ContentLengthError: 400, message='Not enough data to satisfy content length header
(received 19292859 of 19292859 bytes).'>
Note received == expected — every byte of the body was received and fed to the
parser, yet ContentLengthError is raised.
Root cause analysis (source inspection of 3.14.3)
The regression was introduced by the parser pause machinery added in #11966
(3.14.0). Sequence of events:
- The application consumes the
StreamReader slowly, so the buffer exceeds the
high-water mark and flow control pauses reading:
StreamReader → protocol.pause_reading() → the (C) parser sets
_paused = True.
- In
_http_parser.pyx, cb_on_body feeds the final chunk to the payload and
then returns HPE_PAUSED because pyparser._paused is set. llhttp stops
before on_message_complete fires, even though content_length has been
fully consumed. self._payload therefore stays active.
- The server (S3) already sent the whole body and half-closed; once the
transport delivers the close, client_proto.connection_lost() calls
parser.feed_eof().
feed_eof() in _http_parser.pyx only checks the F_CONTENT_LENGTH flag
and raises unconditionally. At that point cparser.content_length == 0, so
the message reads "received N of N bytes".
The key asymmetry: the read-until-EOF branch of feed_eof() has an
_eof_pending mechanism that defers EOF handling while the parser is paused,
but the content-length (and chunked) branches do not. The pure-Python parser
(http_parser.py, HttpPayloadParser.feed_eof) has the same asymmetry.
3.13.5 is not affected: its parser has no pause state, so the fully-fed body
completes the message immediately.
To Reproduce
Deterministic self-contained reproduction (verified: fails on 3.14.3, completes
on 3.13.5). TLS is required — with plain TCP the paused transport defers the
EOF, but SSLProtocol keeps delivering already-buffered decrypted data and the
close while the application-level transport is paused, which is exactly the S3
situation. A large SO_SNDBUF lets the server hand the whole body to the
kernel and close immediately (early close, like S3).
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 \
-nodes -subj "/CN=127.0.0.1"
python repro.py
# repro.py — aiohttp 3.14.3: ClientPayloadError/ContentLengthError
# aiohttp 3.13.5: prints "done 6000000"
import asyncio
import pathlib
import socket
import ssl
import aiohttp
HERE = pathlib.Path(__file__).parent
SIZE = 6_000_000
THROTTLE_BPS = 200_000 # slow consumer -> flow-control pause
async def serve(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
sock = writer.get_extra_info("socket")
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 8 * 1024 * 1024)
await reader.readline()
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n" % SIZE + b"x" * SIZE)
await writer.drain()
writer.close() # close right after the last byte reaches the kernel, like S3
async def main() -> None:
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(HERE / "cert.pem", HERE / "key.pem")
server = await asyncio.start_server(serve, "127.0.0.1", 8443, ssl=server_ctx)
async with server:
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(ssl=False)
) as session:
async with session.get("https://127.0.0.1:8443/") as resp:
n = 0
loop = asyncio.get_running_loop()
start = loop.time()
async for chunk in resp.content.iter_chunked(64 * 1024):
n += len(chunk)
expected = n / THROTTLE_BPS
elapsed = loop.time() - start
if expected > elapsed:
await asyncio.sleep(expected - elapsed)
print("done", n)
asyncio.run(main())
Originally observed downloading ~19 MB objects from S3 via presigned HTTPS URLs
with 3 concurrent throttled downloads (41.7 KB/s each): the first download to
enter the "server finished sending, client still draining" state failed nearly
every cycle.
Expected behavior
The response completes successfully: all bytes were received; a paused parser
receiving EOF after the body is fully fed should complete the message (or defer
EOF like the read-until-EOF branch does) instead of raising ContentLengthError.
Logs/tracebacks
aiohttp.client_exceptions.ClientPayloadError: Response payload is not completed:
<ContentLengthError: 400, message='Not enough data to satisfy content length header
(received 19292859 of 19292859 bytes).'>
Python Version
3.12 / 3.14 (reproduced on both)
aiohttp Version
3.14.3 (regression introduced in 3.14.0; 3.13.5 unaffected)
Related
OS
Linux (Raspberry Pi OS / Debian), also reproduced on macOS
Additional context
Workarounds we found: pin aiohttp<3.14, avoid long draining windows
(range-split downloads), or raise read_bufsize above the object size so flow
control never pauses.
Describe the bug
Since aiohttp 3.14.0, a client that consumes a large
Content-Lengthresponse slowly(e.g. application-level bandwidth throttling with
asyncio.sleepbetweeniter_chunked()reads) fails with a contradictory error when the server sends thewhole body quickly and then half-closes the connection (as Amazon S3 does):
Note received == expected — every byte of the body was received and fed to the
parser, yet
ContentLengthErroris raised.Root cause analysis (source inspection of 3.14.3)
The regression was introduced by the parser pause machinery added in #11966
(3.14.0). Sequence of events:
StreamReaderslowly, so the buffer exceeds thehigh-water mark and flow control pauses reading:
StreamReader→protocol.pause_reading()→ the (C) parser sets_paused = True._http_parser.pyx,cb_on_bodyfeeds the final chunk to the payload andthen returns
HPE_PAUSEDbecausepyparser._pausedis set. llhttp stopsbefore
on_message_completefires, even thoughcontent_lengthhas beenfully consumed.
self._payloadtherefore stays active.transport delivers the close,
client_proto.connection_lost()callsparser.feed_eof().feed_eof()in_http_parser.pyxonly checks theF_CONTENT_LENGTHflagand raises unconditionally. At that point
cparser.content_length == 0, sothe message reads "received N of N bytes".
The key asymmetry: the read-until-EOF branch of
feed_eof()has an_eof_pendingmechanism that defers EOF handling while the parser is paused,but the content-length (and chunked) branches do not. The pure-Python parser
(
http_parser.py,HttpPayloadParser.feed_eof) has the same asymmetry.3.13.5 is not affected: its parser has no pause state, so the fully-fed body
completes the message immediately.
To Reproduce
Deterministic self-contained reproduction (verified: fails on 3.14.3, completes
on 3.13.5). TLS is required — with plain TCP the paused transport defers the
EOF, but
SSLProtocolkeeps delivering already-buffered decrypted data and theclose while the application-level transport is paused, which is exactly the S3
situation. A large
SO_SNDBUFlets the server hand the whole body to thekernel and close immediately (early close, like S3).
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 \ -nodes -subj "/CN=127.0.0.1" python repro.pyOriginally observed downloading ~19 MB objects from S3 via presigned HTTPS URLs
with 3 concurrent throttled downloads (41.7 KB/s each): the first download to
enter the "server finished sending, client still draining" state failed nearly
every cycle.
Expected behavior
The response completes successfully: all bytes were received; a paused parser
receiving EOF after the body is fully fed should complete the message (or defer
EOF like the read-until-EOF branch does) instead of raising
ContentLengthError.Logs/tracebacks
Python Version
3.12 / 3.14 (reproduced on both)
aiohttp Version
3.14.3 (regression introduced in 3.14.0; 3.13.5 unaffected)
Related
contradictory "received N of N" became visible)
sock_readtimeout is re-armed on a keep-alive connection after it returns to the pool, poisoning it (regression in 3.14) #12953, DeflateBuffer.feed_data raises IndexError on empty chunks #12994, Fix segfault when decompression fails while draining pending data #13249 (other 3.14 issues in the same pause/drain machinery)OS
Linux (Raspberry Pi OS / Debian), also reproduced on macOS
Additional context
Workarounds we found: pin
aiohttp<3.14, avoid long draining windows(range-split downloads), or raise
read_bufsizeabove the object size so flowcontrol never pauses.