Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES/12753.misc.rst
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 6 additions & 1 deletion aiohttp/_http_parser.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -365,6 +366,7 @@ cdef class HttpParser:
cparser.llhttp_init(self._cparser, mode, self._csettings)
self._cparser.data = <void*>self
self._cparser.content_length = 0
self._content_length_expected = 0

self.protocol = protocol
self._loop = loop
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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'))
Expand Down
5 changes: 4 additions & 1 deletion aiohttp/http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
114 changes: 114 additions & 0 deletions tests/test_http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
12 changes: 9 additions & 3 deletions tests/test_web_sendfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading