Skip to content

Commit 26fde21

Browse files
Fix parser when paused at end of content-length (aio-libs#13349)
1 parent 6264834 commit 26fde21

4 files changed

Lines changed: 132 additions & 8 deletions

File tree

CHANGES/13348.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed the HTTP parser raising :exc:`~aiohttp.ClientPayloadError` when a fully received ``Content-Length`` body was pending completion -- by :user:`Dreamsorcerer`.

aiohttp/_http_parser.pyx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,11 @@ cdef class HttpParser:
554554
self._messages.append((msg, payload))
555555

556556
cdef _on_message_complete(self):
557-
self._payload.feed_eof()
558-
self._payload = None
557+
# The payload is None when feed_eof() already completed a fully
558+
# received content-length body.
559+
if self._payload is not None:
560+
self._payload.feed_eof()
561+
self._payload = None
559562

560563
cdef _on_chunk_header(self):
561564
self._payload.begin_http_chunk_receiving()
@@ -595,7 +598,8 @@ cdef class HttpParser:
595598
if self._cparser.flags & cparser.F_CHUNKED:
596599
raise TransferEncodingError(
597600
"Not enough data to satisfy transfer length header.")
598-
elif self._cparser.flags & cparser.F_CONTENT_LENGTH:
601+
elif (self._cparser.flags & cparser.F_CONTENT_LENGTH
602+
and self._cparser.content_length):
599603
received = self._content_length_expected - self._cparser.content_length
600604
raise ContentLengthError(
601605
f"Not enough data to satisfy content length header "
@@ -604,6 +608,8 @@ cdef class HttpParser:
604608
desc = cparser.llhttp_get_error_reason(self._cparser)
605609
raise PayloadEncodingError(desc.decode('latin-1'))
606610
else:
611+
# Reading until EOF, or a content-length body that was fully
612+
# received but the parser paused.
607613
self._eof_pending = True
608614
while self._more_data_available:
609615
if self._paused:

aiohttp/http_parser.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -914,11 +914,20 @@ def feed_eof(self) -> None:
914914
self.done = True
915915
self._eof_pending = False
916916
elif self._type == ParseState.PARSE_LENGTH:
917-
received = self._length_expected - self._length
918-
raise ContentLengthError(
919-
f"Not enough data to satisfy content length header "
920-
f"(received {received} of {self._length_expected} bytes)."
921-
)
917+
if self._length:
918+
received = self._length_expected - self._length
919+
raise ContentLengthError(
920+
f"Not enough data to satisfy content length header "
921+
f"(received {received} of {self._length_expected} bytes)."
922+
)
923+
# Body has already been received, but parser paused.
924+
while self._more_data_available:
925+
if self._paused:
926+
self._paused = False
927+
return # Will resume via feed_data(b"") later
928+
self._more_data_available = self.payload.feed_data(b"")
929+
self.payload.feed_eof()
930+
self.done = True
922931
elif self._type == ParseState.PARSE_CHUNKED:
923932
raise TransferEncodingError(
924933
"Not enough data to satisfy transfer length header."

tests/test_http_parser.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,79 @@ async def test_compressed_until_eof_with_pending(response: HttpResponseParser) -
15641564
assert result == original
15651565

15661566

1567+
async def test_content_length_eof_while_paused(response: HttpResponseParser) -> None:
1568+
"""EOF right after a fully received content-length body must complete it.
1569+
1570+
Regression test for #13348:
1571+
feeding the final body bytes pauses the parser for flow control before
1572+
the message can complete; a server closing the connection in that state
1573+
raised ContentLengthError despite received == expected.
1574+
"""
1575+
# Must be large enough to exceed the high water mark so the parser
1576+
# pauses with the message not yet complete.
1577+
body = b"x" * (1024 * 1024)
1578+
headers = b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n" % len(body)
1579+
1580+
msgs, upgrade, tail = response.feed_data(headers + body)
1581+
payload = msgs[0][-1]
1582+
# The server has sent everything and closed the connection.
1583+
response.feed_eof()
1584+
1585+
result = await payload.read()
1586+
assert result == body
1587+
assert payload.is_eof()
1588+
assert payload.exception() is None
1589+
1590+
1591+
async def test_compressed_content_length_eof_while_paused(
1592+
response: HttpResponseParser,
1593+
) -> None:
1594+
"""EOF with pending decompressed data on a complete content-length body.
1595+
1596+
Like test_content_length_eof_while_paused, but the decompressor still
1597+
holds pending data at EOF, so completion is deferred until the reader
1598+
drains it.
1599+
"""
1600+
# Must be large enough to exceed high water mark.
1601+
original = b"B" * 5 * 1024 * 1024
1602+
compressed = zlib.compress(original)
1603+
headers = (
1604+
b"HTTP/1.1 200 OK\r\n"
1605+
b"Content-Length: " + str(len(compressed)).encode() + b"\r\n"
1606+
b"Content-Encoding: deflate\r\n"
1607+
b"\r\n"
1608+
)
1609+
1610+
msgs, upgrade, tail = response.feed_data(headers + compressed)
1611+
payload = msgs[0][-1]
1612+
response.feed_eof()
1613+
1614+
# Check that .feed_eof() hasn't decompressed entire payload into memory.
1615+
assert sum(len(b) for b in payload._buffer) <= (2 * 1024 * 1024)
1616+
1617+
result = await payload.read()
1618+
assert len(result) == len(original)
1619+
assert result == original
1620+
assert payload.is_eof()
1621+
assert payload.exception() is None
1622+
1623+
1624+
async def test_content_length_eof_while_paused_incomplete(
1625+
response: HttpResponseParser,
1626+
) -> None:
1627+
"""EOF on a paused parser with a genuinely incomplete body still raises."""
1628+
body = b"x" * (1024 * 1024)
1629+
headers = b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n" % (len(body) + 1)
1630+
1631+
response.feed_data(headers + body)
1632+
1633+
with pytest.raises(
1634+
http_exceptions.ContentLengthError,
1635+
match=r"received 1048576 of 1048577 bytes",
1636+
):
1637+
response.feed_eof()
1638+
1639+
15671640
async def test_compressed_until_eof_high_water(
15681641
response_cls: type[HttpResponseParser],
15691642
) -> None:
@@ -2809,6 +2882,41 @@ async def test_parse_length_payload_partial_data(
28092882
):
28102883
p.feed_eof()
28112884

2885+
async def test_parse_length_payload_eof_completes_after_pause(
2886+
self, protocol: BaseProtocol
2887+
) -> None:
2888+
"""feed_eof() completes a fully received length payload despite a pause.
2889+
2890+
Regression test for #13348:
2891+
The parser paused for flow control with pending decompressed data
2892+
when EOF arrived; the fully received body must complete instead of
2893+
raising ContentLengthError.
2894+
"""
2895+
out = aiohttp.StreamReader(protocol, 2**16, loop=asyncio.get_running_loop())
2896+
original = b"x" * (1024 * 1024)
2897+
compressed = zlib.compress(original)
2898+
2899+
p = HttpPayloadParser(
2900+
out,
2901+
length=len(compressed),
2902+
compression="deflate",
2903+
headers_parser=HeadersParser(),
2904+
)
2905+
p.pause_reading() # flow control kicked in before the final bytes
2906+
state, tail = p.feed_data(compressed)
2907+
assert state is PayloadState.PAYLOAD_HAS_PENDING_INPUT
2908+
assert not p.done
2909+
2910+
# All bytes were received, so EOF drains the pending data and
2911+
# completes the payload.
2912+
p.feed_eof()
2913+
2914+
assert p.done
2915+
assert out.is_eof() # type: ignore[unreachable]
2916+
assert out.exception() is None
2917+
result = await out.read()
2918+
assert result == original
2919+
28122920
async def test_parse_chunked_payload_size_error(
28132921
self, protocol: BaseProtocol
28142922
) -> None:

0 commit comments

Comments
 (0)