Skip to content

Improve ContentLengthError messages to show expected vs received bytes #11355

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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/11355.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`.
23 changes: 22 additions & 1 deletion aiohttp/_http_parser.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -340,6 +343,8 @@ cdef class HttpParser:
cparser.llhttp_init(self._cparser, mode, self._csettings)
self._cparser.data = <void*>self
self._cparser.content_length = 0
self._content_length_received = 0
self._original_content_length = 0

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

Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -759,6 +775,11 @@ cdef int cb_on_body(cparser.llhttp_t* parser,
const char *at, size_t length) except -1:
cdef HttpParser pyparser = <HttpParser>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:
Expand Down
8 changes: 7 additions & 1 deletion aiohttp/http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link
Member

@Dreamsorcerer Dreamsorcerer Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way we can get the else condition here? My understanding is that it shouldn't be possible. If we receive the number of bytes expected, then we consider that message complete and read any further bytes as the start of the next message. Therefore, we should never be able to have more than the expected number of bytes (I also don't see any equivalent logic in the Cython changes).

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(
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 @@ -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:
Expand Down Expand Up @@ -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
Loading