Skip to content

Commit 5f50bac

Browse files
[PR aio-libs#12217 backport][3.14] Raise on redirect with consumed non-rewindable request bodies (aio-libs#12245)
1 parent 5242641 commit 5f50bac

6 files changed

Lines changed: 87 additions & 30 deletions

File tree

CHANGES/12195.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed redirects with consumed non-rewindable request bodies to raise
2+
:class:`aiohttp.ClientPayloadError` instead of silently sending an empty body.

aiohttp/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,18 @@ async def _connect_and_send_request(
876876
# For 307/308, always preserve the request body
877877
# For 301/302 with non-POST methods, preserve the request body
878878
# https://www.rfc-editor.org/rfc/rfc9110#section-15.4.3-3.1
879-
# Use the existing payload to avoid recreating it from a potentially consumed file
879+
# Use the existing payload to avoid recreating it from
880+
# a potentially consumed file.
881+
#
882+
# If the payload is already consumed and cannot be replayed,
883+
# fail fast instead of silently sending an empty body.
884+
if req._body is not None and req._body.consumed:
885+
resp.close()
886+
raise ClientPayloadError(
887+
"Cannot follow redirect with a consumed request "
888+
"body. Use bytes, a seekable file-like object, "
889+
"or set allow_redirects=False."
890+
)
880891
data = req._body
881892

882893
r_url = resp.headers.get(hdrs.LOCATION) or resp.headers.get(

docs/client_quickstart.rst

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -343,25 +343,34 @@ send large files without reading them into memory.
343343

344344
As a simple case, simply provide a file-like object for your body::
345345

346-
with open('massive-body', 'rb') as f:
347-
await session.post('http://httpbin.org/post', data=f)
346+
with open("massive-body", "rb") as f:
347+
await session.post("https://httpbin.org/post", data=f)
348348

349349

350-
Or you can use *asynchronous generator*::
350+
Or you can provide an *asynchronous generator*, for example to generate
351+
data on the fly::
351352

352-
async def file_sender(file_name=None):
353-
async with aiofiles.open(file_name, 'rb') as f:
354-
chunk = await f.read(64*1024)
355-
while chunk:
356-
yield chunk
357-
chunk = await f.read(64*1024)
353+
async def data_generator():
354+
for i in range(10):
355+
yield f"line {i}\n".encode()
358356

359-
# Then you can use file_sender as a data provider:
360-
361-
async with session.post('http://httpbin.org/post',
362-
data=file_sender(file_name='huge_file')) as resp:
357+
async with session.post("https://httpbin.org/post",
358+
data=data_generator()) as resp:
363359
print(await resp.text())
364360

361+
.. warning::
362+
363+
Async generators and other non-rewindable data sources
364+
(such as :class:`~aiohttp.StreamReader`) cannot be replayed if a
365+
redirect occurs (for example, HTTP 307 or 308). If the request body
366+
has already been streamed, :mod:`aiohttp` raises
367+
:class:`~aiohttp.ClientPayloadError`.
368+
369+
If your endpoint may redirect, either:
370+
371+
* Pass a seekable file-like object or :class:`bytes`.
372+
* Disable redirects with ``allow_redirects=False`` and handle them manually.
373+
365374

366375
Because the :attr:`~aiohttp.ClientResponse.content` attribute is a
367376
:class:`~aiohttp.StreamReader` (provides async iterator protocol), you

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ sa
304304
Satisfiable
305305
scalability
306306
schemas
307+
seekable
307308
sendfile
308309
serializable
309310
serializer

tests/test_client_functional.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5033,7 +5033,7 @@ async def final_handler(request: web.Request) -> web.Response:
50335033

50345034

50355035
async def test_async_iterable_payload_redirect(aiohttp_client: AiohttpClient) -> None:
5036-
"""Test that AsyncIterablePayload cannot be reused across redirects."""
5036+
"""Test redirecting consumed AsyncIterablePayload raises an error."""
50375037
data_received = []
50385038

50395039
async def redirect_handler(request: web.Request) -> web.Response:
@@ -5061,17 +5061,50 @@ async def async_gen() -> AsyncIterator[bytes]:
50615061

50625062
payload = AsyncIterablePayload(async_gen())
50635063

5064-
resp = await client.post("/redirect", data=payload)
5065-
assert resp.status == 200
5066-
text = await resp.text()
5067-
# AsyncIterablePayload is consumed after first use, so redirect gets empty body
5068-
assert text == "Received: "
5064+
with pytest.raises(
5065+
aiohttp.ClientPayloadError,
5066+
match="Cannot follow redirect with a consumed request body",
5067+
):
5068+
await client.post("/redirect", data=payload)
5069+
5070+
# Only the first endpoint should have received data.
5071+
expected_data = b"".join(chunks)
5072+
assert data_received == [("redirect", expected_data)]
5073+
5074+
5075+
@pytest.mark.parametrize("status", (301, 302))
5076+
async def test_async_iterable_payload_redirect_non_post_301_302(
5077+
aiohttp_client: AiohttpClient, status: int
5078+
) -> None:
5079+
"""Test consumed async iterable body raises on 301/302 for non-POST methods."""
5080+
data_received = []
5081+
5082+
async def redirect_handler(request: web.Request) -> web.Response:
5083+
data = await request.read()
5084+
data_received.append(("redirect", data))
5085+
return web.Response(status=status, headers={"Location": "/final_destination"})
5086+
5087+
app = web.Application()
5088+
app.router.add_put("/redirect", redirect_handler)
5089+
5090+
client = await aiohttp_client(app)
5091+
5092+
chunks = [b"chunk1", b"chunk2", b"chunk3"]
5093+
5094+
async def async_gen() -> AsyncIterator[bytes]:
5095+
for chunk in chunks:
5096+
yield chunk
5097+
5098+
payload = AsyncIterablePayload(async_gen())
5099+
5100+
with pytest.raises(
5101+
aiohttp.ClientPayloadError,
5102+
match="Cannot follow redirect with a consumed request body",
5103+
):
5104+
await client.put("/redirect", data=payload)
50695105

5070-
# Only the first endpoint should have received data
50715106
expected_data = b"".join(chunks)
5072-
assert len(data_received) == 2
5073-
assert data_received[0] == ("redirect", expected_data)
5074-
assert data_received[1] == ("final", b"") # Empty after being consumed
5107+
assert data_received == [("redirect", expected_data)]
50755108

50765109

50775110
async def test_buffered_reader_payload_redirect(aiohttp_client: AiohttpClient) -> None:

tests/test_client_ws_functional.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -886,12 +886,14 @@ async def test_heartbeat_does_not_timeout_while_receiving_large_frame(
886886
which could cause a ping/pong timeout while bytes were still being received.
887887
"""
888888
payload = b"x" * 2048
889-
heartbeat = 0.05
889+
heartbeat = 0.1
890890
chunk_size = 64
891891
delay = 0.01
892892

893893
async def handler(request: web.Request) -> web.WebSocketResponse:
894-
ws = web.WebSocketResponse()
894+
# Disable auto-PONG so a heartbeat PING during frame streaming would
895+
# surface as a timeout/closure on the client side.
896+
ws = web.WebSocketResponse(autoping=False)
895897
await ws.prepare(request)
896898

897899
assert ws._writer is not None
@@ -918,10 +920,8 @@ async def handler(request: web.Request) -> web.WebSocketResponse:
918920
client = await aiohttp_client(app)
919921

920922
async with client.ws_connect("/", heartbeat=heartbeat) as resp:
921-
# If heartbeat was not reset on any incoming bytes, the client would start
922-
# sending PINGs while we're still streaming the message body, and since the
923-
# server handler never calls receive(), no PONG would be produced and the
924-
# client would close with a ping/pong timeout.
923+
# If heartbeat were not reset on incoming bytes, the client would send
924+
# a PING while this frame is still being streamed.
925925
with mock.patch.object(
926926
resp._writer, "send_frame", wraps=resp._writer.send_frame
927927
) as sf:
@@ -931,6 +931,7 @@ async def handler(request: web.Request) -> web.WebSocketResponse:
931931
), "Heartbeat PING sent while data was still being received"
932932
assert msg.type is WSMsgType.BINARY
933933
assert msg.data == payload
934+
assert not resp.closed
934935

935936

936937
async def test_heartbeat_no_pong_after_receive_many_messages(

0 commit comments

Comments
 (0)