From eeb21d0819b4d6dfb81faad060254915ec3c0794 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 1 Jun 2026 16:33:15 +0100 Subject: [PATCH 1/2] Improve ContentLengthError messages to show expected vs received bytes (#12753) --- CHANGES/12753.misc.rst | 2 + aiohttp/_http_parser.pyx | 7 ++- aiohttp/http_parser.py | 5 +- tests/test_http_parser.py | 114 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 CHANGES/12753.misc.rst diff --git a/CHANGES/12753.misc.rst b/CHANGES/12753.misc.rst new file mode 100644 index 00000000000..0b73044f46a --- /dev/null +++ b/CHANGES/12753.misc.rst @@ -0,0 +1,2 @@ +Improved ``ContentLengthError`` exception messages to include both expected and received byte counts. This enhancement provides better diagnostics when debugging response body size mismatches +-- by :user:`bdraco` and :user:`Dreamsorcerer`. diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index b323fe5a846..e65a8d6cba3 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -327,6 +327,7 @@ cdef class HttpParser: bint _paused bint _eof_pending object _payload + unsigned long long _content_length_expected bint _payload_error object _payload_exception object _last_error @@ -365,6 +366,7 @@ cdef class HttpParser: cparser.llhttp_init(self._cparser, mode, self._csettings) self._cparser.data = self self._cparser.content_length = 0 + self._content_length_expected = 0 self.protocol = protocol self._loop = loop @@ -520,6 +522,7 @@ cdef class HttpParser: payload = EMPTY_PAYLOAD self._payload = payload + self._content_length_expected = self._cparser.content_length if encoding is not None and self._auto_decompress: self._payload = DeflateBuffer(payload, encoding, max_decompress_size=self._limit) @@ -563,8 +566,10 @@ cdef class HttpParser: raise TransferEncodingError( "Not enough data to satisfy transfer length header.") elif self._cparser.flags & cparser.F_CONTENT_LENGTH: + received = self._content_length_expected - self._cparser.content_length raise ContentLengthError( - "Not enough data to satisfy content length header.") + f"Not enough data to satisfy content length header " + f"(received {received} of {self._content_length_expected} bytes).") elif cparser.llhttp_get_errno(self._cparser) != cparser.HPE_OK: desc = cparser.llhttp_get_error_reason(self._cparser) raise PayloadEncodingError(desc.decode('latin-1')) diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 468218f6c51..cfd56c0997e 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -853,6 +853,7 @@ def __init__( elif length is not None: self._type = ParseState.PARSE_LENGTH self._length = length + self._length_expected = length if self._length == 0: real_payload.feed_eof() self.done = True @@ -874,8 +875,10 @@ def feed_eof(self) -> None: self.done = True self._eof_pending = False elif self._type == ParseState.PARSE_LENGTH: + received = self._length_expected - self._length raise ContentLengthError( - "Not enough data to satisfy content length header." + f"Not enough data to satisfy content length header " + f"(received {received} of {self._length_expected} bytes)." ) elif self._type == ParseState.PARSE_CHUNKED: raise TransferEncodingError( diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index f0e19325005..6cb5dce5394 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -2322,6 +2322,52 @@ async def test_parse_length_payload_eof(self, protocol: BaseProtocol) -> None: with pytest.raises(http_exceptions.ContentLengthError): p.feed_eof() + async def test_parse_length_payload_eof_error_message( + self, protocol: BaseProtocol + ) -> None: + """Test that ContentLengthError includes expected vs received bytes.""" + out = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop()) + + # Expect 10 bytes, but only send 3 + p = HttpPayloadParser(out, length=10, headers_parser=HeadersParser()) + p.feed_data(b"abc") + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"received 3 of 10 bytes" + ): + p.feed_eof() + + async def test_parse_length_payload_eof_no_data( + self, protocol: BaseProtocol + ) -> None: + """Test ContentLengthError when no data is received.""" + out = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop()) + + # Expect 20 bytes, but send nothing + p = HttpPayloadParser(out, length=20, headers_parser=HeadersParser()) + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"received 0 of 20 bytes" + ): + p.feed_eof() + + async def test_parse_length_payload_partial_data( + self, protocol: BaseProtocol + ) -> None: + """Test ContentLengthError with various amounts of partial data.""" + out = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop()) + + # Expect 100 bytes, but only send 45 + p = HttpPayloadParser(out, length=100, headers_parser=HeadersParser()) + p.feed_data(b"a" * 25) + p.feed_data(b"b" * 20) + + with pytest.raises( + http_exceptions.ContentLengthError, + match=r"received 45 of 100 bytes", + ): + p.feed_eof() + async def test_parse_chunked_payload_size_error( self, protocol: BaseProtocol ) -> None: @@ -2860,3 +2906,71 @@ async def test_streaming_decompress_large_payload( result = b"".join(buf._buffer) assert len(result) == len(original) assert result == original + + +def test_response_parser_incomplete_body_error_message( + response: HttpResponseParser, +) -> None: + """Test response parser error message for incomplete body.""" + # Response expects 50 bytes + response.feed_data(b"HTTP/1.1 200 OK\r\nContent-Length: 50\r\n\r\n") + # Send only 15 bytes + response.feed_data(b"partial content") + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"received 15 of 50 bytes" + ): + response.feed_eof() + + +def test_response_parser_no_body_error_message(response: HttpResponseParser) -> None: + """Test response parser error when no body is received.""" + # Response expects 25 bytes + response.feed_data(b"HTTP/1.1 200 OK\r\nContent-Length: 25\r\n\r\n") + # Send no body data + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"received 0 of 25 bytes" + ): + response.feed_eof() + + +def test_response_parser_partial_chunks_error_message( + response: HttpResponseParser, +) -> None: + """Test error message when body is sent in multiple chunks.""" + # Response expects 100 bytes + response.feed_data(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n") + # Send data in chunks totaling 60 bytes + response.feed_data(b"a" * 20) + response.feed_data(b"b" * 20) + response.feed_data(b"c" * 20) + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"received 60 of 100 bytes" + ): + response.feed_eof() + + +def test_request_parser_incomplete_body_error_message( + parser: HttpRequestParser, +) -> None: + """Test request parser error message for incomplete body.""" + # Request with Content-Length but incomplete body + parser.feed_data(b"POST /test HTTP/1.1\r\nHost: a\r\nContent-Length: 30\r\n\r\n") + # Send only 10 bytes + parser.feed_data(b"incomplete") + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"received 10 of 30 bytes" + ): + parser.feed_eof() + + +def test_response_content_length_zero_no_error(response: HttpResponseParser) -> None: + """Test that Content-Length: 0 does not raise error on feed_eof.""" + # Response with Content-Length: 0 + response.feed_data(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + + # This should NOT raise an error + response.feed_eof() # Should complete without exception From 0d864ff9644205e5aa860e7598e4984aff6e07a9 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 1 Jun 2026 16:49:13 +0100 Subject: [PATCH 2/2] Fix sendfile test (#12754) --- tests/test_web_sendfile.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_web_sendfile.py b/tests/test_web_sendfile.py index 2679ee935d2..00b755c7ba5 100644 --- a/tests/test_web_sendfile.py +++ b/tests/test_web_sendfile.py @@ -152,13 +152,19 @@ async def test_file_response_sends_headers_immediately() -> None: file_sender = FileResponse(filepath) file_sender._path = filepath - file_sender._sendfile = mock.AsyncMock(return_value=None) # type: ignore[method-assign] # FileResponse inherits from StreamResponse, so should send immediately assert file_sender._send_headers_immediately is True - # Prepare the response - await file_sender.prepare(request) + # Mock the actual transfer but let _sendfile call super().prepare() to write headers + with ( + mock.patch.object( + file_sender, "_sendfile_fallback", autospec=True, spec_set=True + ) as sendfile_fallback, + mock.patch("aiohttp.web_fileresponse.NOSENDFILE", True), + ): + sendfile_fallback.return_value = writer + await file_sender.prepare(request) # Headers should be sent immediately writer.send_headers.assert_called_once()