Skip to content

Commit b084084

Browse files
authored
Rewind file bodies when internally retrying a request (aio-libs#13330)
1 parent 72eaa42 commit b084084

8 files changed

Lines changed: 180 additions & 5 deletions

File tree

CHANGES/13329.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed internally retried requests sending a truncated body when the request
2+
data was a file object -- by :user:`aiolibsbot`.

CHANGES/13330.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
13329.bugfix.rst

aiohttp/client.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -733,10 +733,18 @@ async def _request(
733733
):
734734
raise
735735
except (ClientOSError, ServerDisconnectedError):
736-
if retry_persistent_connection:
737-
retry_persistent_connection = False
738-
continue
739-
raise
736+
if not retry_persistent_connection:
737+
raise
738+
retry_persistent_connection = False
739+
if data is not None:
740+
# Rebuilding from `data` would resend only the unread
741+
# remainder of a file object; reuse the payload, which
742+
# rewinds itself once the cancelled writer has settled.
743+
await req._close()
744+
if req._body.consumed:
745+
raise
746+
data = req._body
747+
continue
740748
except ClientError:
741749
raise
742750
except OSError as exc:

aiohttp/multipart.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,10 @@ async def as_bytes(self, encoding: str = "utf-8", errors: str = "strict") -> byt
663663

664664
async def write(self, writer: AbstractStreamWriter) -> None:
665665
field = self._value
666+
# Reading the part drains the underlying stream irreversibly, so mark the
667+
# payload consumed up front: even an interrupted write leaves nothing that
668+
# a retry or redirect could replay.
669+
self._consumed = True
666670
while chunk := await field.read_chunk(size=DEFAULT_CHUNK_SIZE):
667671
async for d in field.decode_iter(chunk):
668672
await writer.write(d)
@@ -944,6 +948,11 @@ def __exit__(
944948
) -> None:
945949
pass
946950

951+
@property
952+
def consumed(self) -> bool:
953+
"""Whether the writer or any of its parts can no longer be replayed."""
954+
return self._consumed or any(part.consumed for part, _, _ in self._parts)
955+
947956
def __iter__(self) -> Iterator[_Part]:
948957
return iter(self._parts)
949958

aiohttp/payload.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,10 @@ async def write_with_length(
10491049

10501050
# Stream from the iterator
10511051
remaining_bytes = content_length
1052+
# Nothing is cached, so advancing the iterator is irreversible: mark the
1053+
# payload consumed up front so an interrupted write cannot be replayed
1054+
# from a partially drained iterator.
1055+
self._consumed = True
10521056

10531057
try:
10541058
while True:
@@ -1066,7 +1070,6 @@ async def write_with_length(
10661070
except StopAsyncIteration:
10671071
# Iterator is exhausted
10681072
self._iter = None
1069-
self._consumed = True # Mark as consumed when streamed without caching
10701073

10711074
def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str:
10721075
"""Decode the payload content as a string if cached chunks are available."""

tests/test_client_functional.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5895,6 +5895,74 @@ async def handler(request: web.Request) -> web.Response:
58955895
await asyncio.to_thread(f.close)
58965896

58975897

5898+
async def test_file_upload_retry_persistent_connection(
5899+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path
5900+
) -> None:
5901+
"""A retried request must resend the whole file, not the unread remainder."""
5902+
received_bodies: list[bytes] = []
5903+
num_requests = 0
5904+
5905+
async def handler(request: web.Request) -> web.Response:
5906+
nonlocal num_requests
5907+
num_requests += 1
5908+
if num_requests == 1:
5909+
assert request.transport is not None
5910+
request.transport.close()
5911+
return web.Response()
5912+
5913+
received_bodies.append(await request.read())
5914+
return web.Response()
5915+
5916+
app = web.Application()
5917+
app.router.add_put("/upload", handler)
5918+
5919+
client = await aiohttp_client(app)
5920+
client.session._retry_connection = True
5921+
5922+
test_file = tmp_path / "test_retry_upload.txt"
5923+
content = b"This is test file content for a retried upload."
5924+
await asyncio.to_thread(test_file.write_bytes, content)
5925+
5926+
f = await asyncio.to_thread(open, test_file, "rb")
5927+
try:
5928+
async with client.put("/upload", data=f) as resp:
5929+
assert resp.status == 200
5930+
finally:
5931+
await asyncio.to_thread(f.close)
5932+
5933+
assert num_requests == 2
5934+
assert received_bodies == [content]
5935+
5936+
5937+
async def test_upload_retry_persistent_connection_unseekable_body(
5938+
aiohttp_client: AiohttpClient,
5939+
) -> None:
5940+
"""An unreplayable body must not be silently resent truncated on retry."""
5941+
num_requests = 0
5942+
5943+
async def handler(request: web.Request) -> web.Response:
5944+
nonlocal num_requests
5945+
num_requests += 1
5946+
assert request.transport is not None
5947+
request.transport.close()
5948+
return web.Response()
5949+
5950+
app = web.Application()
5951+
app.router.add_put("/upload", handler)
5952+
5953+
client = await aiohttp_client(app)
5954+
client.session._retry_connection = True
5955+
5956+
async def gen() -> AsyncIterator[bytes]:
5957+
yield b"chunk1"
5958+
yield b"chunk2"
5959+
5960+
with pytest.raises((aiohttp.ServerDisconnectedError, aiohttp.ClientOSError)):
5961+
await client.put("/upload", data=gen())
5962+
5963+
assert num_requests == 1
5964+
5965+
58985966
async def test_stream_reader_total_raw_bytes(aiohttp_client: AiohttpClient) -> None:
58995967
"""Test whether StreamReader.total_raw_bytes returns the number of bytes downloaded"""
59005968
source_data = b"@dKal^pH>1h|YW1:c2J$" * 4096

tests/test_multipart.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import pathlib
66
import sys
7+
from collections.abc import AsyncIterator
78
from types import TracebackType
89
from unittest import mock
910

@@ -1835,3 +1836,59 @@ async def test_multipart_writer_close_with_exceptions() -> None:
18351836
await writer.close()
18361837
assert part1.close.call_count == 1
18371838
assert part2.close.call_count == 1
1839+
1840+
1841+
async def test_multipart_writer_consumed_follows_parts() -> None:
1842+
"""A writer holding an unreplayable part must report itself as consumed."""
1843+
1844+
async def gen() -> AsyncIterator[bytes]:
1845+
yield b"chunk1"
1846+
yield b"chunk2"
1847+
1848+
writer = aiohttp.MultipartWriter()
1849+
writer.append(b"replayable")
1850+
assert writer.consumed is False
1851+
1852+
part = writer.append(gen())
1853+
assert writer.consumed is False
1854+
1855+
stream = mock.Mock()
1856+
stream.write = mock.AsyncMock()
1857+
await part.write_with_length(stream, None)
1858+
1859+
assert part.consumed is True
1860+
assert writer.consumed is True
1861+
1862+
1863+
async def test_body_part_reader_payload_consumed_after_write() -> None:
1864+
"""A drained body part reader must report itself as consumed."""
1865+
with Stream(b"Hello, world!\r\n--:--") as stream:
1866+
body_part = aiohttp.BodyPartReader(
1867+
BOUNDARY, HeadersDictProxy(CIMultiDict()), stream
1868+
)
1869+
payload = BodyPartReaderPayload(body_part)
1870+
assert payload.consumed is False
1871+
1872+
writer = mock.Mock()
1873+
writer.write = mock.AsyncMock()
1874+
await payload.write(writer)
1875+
1876+
assert payload.consumed is True
1877+
1878+
1879+
async def test_multipart_writer_consumed_follows_body_part_reader() -> None:
1880+
"""A writer holding a drained body part reader must report itself consumed."""
1881+
with Stream(b"Hello, world!\r\n--:--") as stream:
1882+
body_part = aiohttp.BodyPartReader(
1883+
BOUNDARY, HeadersDictProxy(CIMultiDict()), stream
1884+
)
1885+
writer = aiohttp.MultipartWriter()
1886+
part = writer.append(body_part)
1887+
assert writer.consumed is False
1888+
1889+
out = mock.Mock()
1890+
out.write = mock.AsyncMock()
1891+
await part.write_with_length(out, None)
1892+
1893+
assert part.consumed is True
1894+
assert writer.consumed is True

tests/test_payload.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,33 @@ async def gen() -> AsyncIterator[bytes]:
964964
assert writer2.get_written_bytes() == b""
965965

966966

967+
async def test_async_iterable_payload_consumed_on_interrupted_write() -> None:
968+
"""An interrupted write must still mark an uncached payload as consumed."""
969+
970+
async def gen() -> AsyncIterator[bytes]:
971+
yield b"chunk1"
972+
yield b"chunk2"
973+
974+
class FailingWriter(MockStreamWriter):
975+
async def write(
976+
self,
977+
chunk: Union[bytes, bytearray, "memoryview[int]", "memoryview[bytes]"],
978+
) -> None:
979+
if self.written:
980+
raise ConnectionResetError("connection lost")
981+
await super().write(chunk)
982+
983+
p = payload.AsyncIterablePayload(gen())
984+
writer = FailingWriter()
985+
986+
with pytest.raises(ConnectionResetError):
987+
await p.write_with_length(writer, None)
988+
989+
# The iterator was partially drained, so the payload cannot be replayed.
990+
assert writer.get_written_bytes() == b"chunk1"
991+
assert p.consumed is True
992+
993+
967994
async def test_bytes_io_payload_close_does_not_close_io() -> None:
968995
"""Test that BytesIOPayload close() does not close the underlying BytesIO."""
969996
bytes_io = io.BytesIO(b"data")

0 commit comments

Comments
 (0)