diff --git a/CHANGES/11355.misc.rst b/CHANGES/11355.misc.rst new file mode 100644 index 00000000000..b7d1b063a50 --- /dev/null +++ b/CHANGES/11355.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`. diff --git a/aiohttp/_http_parser.pyx b/aiohttp/_http_parser.pyx index f5015b297b0..00e65b82d8d 100644 --- a/aiohttp/_http_parser.pyx +++ b/aiohttp/_http_parser.pyx @@ -282,6 +282,9 @@ cdef class HttpParser: bytes _raw_value bint _has_value + size_t _content_length_received # Track bytes received for content-length responses + size_t _original_content_length # Store original content length + object _protocol object _loop object _timer @@ -340,6 +343,8 @@ cdef class HttpParser: cparser.llhttp_init(self._cparser, mode, self._csettings) self._cparser.data = self self._cparser.content_length = 0 + self._content_length_received = 0 + self._original_content_length = 0 self._protocol = protocol self._loop = loop @@ -417,6 +422,10 @@ cdef class HttpParser: upgrade = self._cparser.upgrade chunked = self._cparser.flags & cparser.F_CHUNKED + # Store original content length for error reporting + if self._cparser.flags & cparser.F_CONTENT_LENGTH: + self._original_content_length = self._cparser.content_length + raw_headers = tuple(self._raw_headers) headers = CIMultiDictProxy(CIMultiDict(self._headers)) @@ -508,8 +517,13 @@ cdef class HttpParser: raise TransferEncodingError( "Not enough data to satisfy transfer length header.") elif self._cparser.flags & cparser.F_CONTENT_LENGTH: + # Get expected content length and received bytes + expected = self._original_content_length + received = self._content_length_received raise ContentLengthError( - "Not enough data to satisfy content length header.") + f"Not enough data to satisfy content length header. " + f"Expected {expected} bytes, got {received} 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')) @@ -668,6 +682,8 @@ cdef int cb_on_message_begin(cparser.llhttp_t* parser) except -1: pyparser._started = True pyparser._headers = [] pyparser._raw_headers = [] + pyparser._content_length_received = 0 # Reset counter for new message + pyparser._original_content_length = 0 # Reset original content length PyByteArray_Resize(pyparser._buf, 0) pyparser._path = None pyparser._reason = None @@ -759,6 +775,11 @@ cdef int cb_on_body(cparser.llhttp_t* parser, const char *at, size_t length) except -1: cdef HttpParser pyparser = parser.data cdef bytes body = at[:length] + + # Track bytes received for content-length responses + if parser.flags & cparser.F_CONTENT_LENGTH: + pyparser._content_length_received += length + try: pyparser._payload.feed_data(body) except BaseException as underlying_exc: diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py index 84b59afc486..ae2e109c0a4 100644 --- a/aiohttp/http_parser.py +++ b/aiohttp/http_parser.py @@ -768,6 +768,7 @@ def __init__( headers_parser: HeadersParser, ) -> None: self._length = 0 + self._original_length = length # Store original expected length self._type = ParseState.PARSE_UNTIL_EOF self._chunk = ChunkState.PARSE_CHUNKED_SIZE self._chunk_size = 0 @@ -807,8 +808,13 @@ def feed_eof(self) -> None: if self._type == ParseState.PARSE_UNTIL_EOF: self.payload.feed_eof() elif self._type == ParseState.PARSE_LENGTH: + # Calculate how much we received vs expected + expected = self._original_length if self._original_length is not None else 0 + # self._length is decremented as data is received, so received = expected - remaining + received = expected - self._length if self._length <= expected else 0 raise ContentLengthError( - "Not enough data to satisfy content length header." + f"Not enough data to satisfy content length header. " + f"Expected {expected} bytes, got {received} bytes." ) elif self._type == ParseState.PARSE_CHUNKED: raise TransferEncodingError( diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py index 69e1fd7d4cc..968d122d581 100644 --- a/tests/test_http_parser.py +++ b/tests/test_http_parser.py @@ -1653,6 +1653,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"Expected 10 bytes, got 3 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"Expected 20 bytes, got 0 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"Expected 100 bytes, got 45 bytes", + ): + p.feed_eof() + async def test_parse_chunked_payload_size_error( self, protocol: BaseProtocol ) -> None: @@ -1936,3 +1982,71 @@ async def test_empty_body(self, protocol: BaseProtocol) -> None: dbuf.feed_eof() assert buf.at_eof() + + +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"Expected 50 bytes, got 15 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"Expected 25 bytes, got 0 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"Expected 100 bytes, got 60 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\nContent-Length: 30\r\n\r\n") + # Send only 10 bytes + parser.feed_data(b"incomplete") + + with pytest.raises( + http_exceptions.ContentLengthError, match=r"Expected 30 bytes, got 10 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