Skip to content

ContentLengthError raised despite all bytes received: paused parser drops on_message_complete when server closes early (3.14 regression) #13348

Description

@Odacchi

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:

  1. The application consumes the StreamReader slowly, so the buffer exceeds the
    high-water mark and flow control pauses reading:
    StreamReaderprotocol.pause_reading() → the (C) parser sets
    _paused = True.
  2. 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.
  3. 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().
  4. 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.

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