Skip to content

Commit 13b20a1

Browse files
[PR #11290/16703bb9 backport][3.12] Fix file uploads failing with HTTP 422 on 307/308 redirects (#11296)
**This is a backport of PR #11290 as merged into master (16703bb).** --------- Co-authored-by: J. Nick Koston <[email protected]>
1 parent edf2abd commit 13b20a1

File tree

5 files changed

+335
-4
lines changed

5 files changed

+335
-4
lines changed

CHANGES/11270.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed file uploads failing with HTTP 422 errors when encountering 307/308 redirects, and 301/302 redirects for non-POST methods, by preserving the request body when appropriate per :rfc:`9110#section-15.4.3-3.1` -- by :user:`bdraco`.

aiohttp/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,12 @@ async def _connect_and_send_request(
821821
data = None
822822
if headers.get(hdrs.CONTENT_LENGTH):
823823
headers.pop(hdrs.CONTENT_LENGTH)
824+
else:
825+
# For 307/308, always preserve the request body
826+
# For 301/302 with non-POST methods, preserve the request body
827+
# https://www.rfc-editor.org/rfc/rfc9110#section-15.4.3-3.1
828+
# Use the existing payload to avoid recreating it from a potentially consumed file
829+
data = req._body
824830

825831
r_url = resp.headers.get(hdrs.LOCATION) or resp.headers.get(
826832
hdrs.URI

aiohttp/payload.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -486,10 +486,14 @@ def _set_or_restore_start_position(self) -> None:
486486
if self._start_position is None:
487487
try:
488488
self._start_position = self._value.tell()
489-
except OSError:
489+
except (OSError, AttributeError):
490490
self._consumed = True # Cannot seek, mark as consumed
491491
return
492-
self._value.seek(self._start_position)
492+
try:
493+
self._value.seek(self._start_position)
494+
except (OSError, AttributeError):
495+
# Failed to seek back - mark as consumed since we've already read
496+
self._consumed = True
493497

494498
def _read_and_available_len(
495499
self, remaining_content_len: Optional[int]
@@ -540,11 +544,30 @@ def size(self) -> Optional[int]:
540544
"""
541545
Size of the payload in bytes.
542546
543-
Returns the number of bytes remaining to be read from the file.
547+
Returns the total size of the payload content from the initial position.
548+
This ensures consistent Content-Length for requests, including 307/308 redirects
549+
where the same payload instance is reused.
550+
544551
Returns None if the size cannot be determined (e.g., for unseekable streams).
545552
"""
546553
try:
547-
return os.fstat(self._value.fileno()).st_size - self._value.tell()
554+
# Store the start position on first access.
555+
# This is critical when the same payload instance is reused (e.g., 307/308
556+
# redirects). Without storing the initial position, after the payload is
557+
# read once, the file position would be at EOF, which would cause the
558+
# size calculation to return 0 (file_size - EOF position).
559+
# By storing the start position, we ensure the size calculation always
560+
# returns the correct total size for any subsequent use.
561+
if self._start_position is None:
562+
try:
563+
self._start_position = self._value.tell()
564+
except (OSError, AttributeError):
565+
# Can't get position, can't determine size
566+
return None
567+
568+
# Return the total size from the start position
569+
# This ensures Content-Length is correct even after reading
570+
return os.fstat(self._value.fileno()).st_size - self._start_position
548571
except (AttributeError, OSError):
549572
return None
550573

tests/test_client_functional.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5286,3 +5286,228 @@ async def handler(request: web.Request) -> web.Response:
52865286
assert (
52875287
len(resp._raw_cookie_headers) == 12
52885288
), "All raw headers should be preserved"
5289+
5290+
5291+
@pytest.mark.parametrize("status", (307, 308))
5292+
async def test_file_upload_307_308_redirect(
5293+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path, status: int
5294+
) -> None:
5295+
"""Test that file uploads work correctly with 307/308 redirects.
5296+
5297+
This demonstrates the bug where file payloads get incorrect Content-Length
5298+
on redirect because the file position isn't reset.
5299+
"""
5300+
received_bodies: list[bytes] = []
5301+
5302+
async def handler(request: web.Request) -> web.Response:
5303+
# Store the body content
5304+
body = await request.read()
5305+
received_bodies.append(body)
5306+
5307+
if str(request.url.path).endswith("/"):
5308+
# Redirect URLs ending with / to remove the trailing slash
5309+
return web.Response(
5310+
status=status,
5311+
headers={
5312+
"Location": str(request.url.with_path(request.url.path.rstrip("/")))
5313+
},
5314+
)
5315+
5316+
# Return success with the body size
5317+
return web.json_response(
5318+
{
5319+
"received_size": len(body),
5320+
"content_length": request.headers.get("Content-Length"),
5321+
}
5322+
)
5323+
5324+
app = web.Application()
5325+
app.router.add_post("/upload/", handler)
5326+
app.router.add_post("/upload", handler)
5327+
5328+
client = await aiohttp_client(app)
5329+
5330+
# Create a test file
5331+
test_file = tmp_path / f"test_upload_{status}.txt"
5332+
content = b"This is test file content for upload."
5333+
await asyncio.to_thread(test_file.write_bytes, content)
5334+
expected_size = len(content)
5335+
5336+
# Upload file to URL with trailing slash (will trigger redirect)
5337+
f = await asyncio.to_thread(open, test_file, "rb")
5338+
try:
5339+
async with client.post("/upload/", data=f) as resp:
5340+
assert resp.status == 200
5341+
result = await resp.json()
5342+
5343+
# The server should receive the full file content
5344+
assert result["received_size"] == expected_size
5345+
assert result["content_length"] == str(expected_size)
5346+
5347+
# Both requests should have received the same content
5348+
assert len(received_bodies) == 2
5349+
assert received_bodies[0] == content # First request
5350+
assert received_bodies[1] == content # After redirect
5351+
finally:
5352+
await asyncio.to_thread(f.close)
5353+
5354+
5355+
@pytest.mark.parametrize("status", [301, 302])
5356+
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
5357+
async def test_file_upload_301_302_redirect_non_post(
5358+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path, status: int, method: str
5359+
) -> None:
5360+
"""Test that file uploads work correctly with 301/302 redirects for non-POST methods.
5361+
5362+
Per RFC 9110, 301/302 redirects should preserve the method and body for non-POST requests.
5363+
"""
5364+
received_bodies: list[bytes] = []
5365+
5366+
async def handler(request: web.Request) -> web.Response:
5367+
# Store the body content
5368+
body = await request.read()
5369+
received_bodies.append(body)
5370+
5371+
if str(request.url.path).endswith("/"):
5372+
# Redirect URLs ending with / to remove the trailing slash
5373+
return web.Response(
5374+
status=status,
5375+
headers={
5376+
"Location": str(request.url.with_path(request.url.path.rstrip("/")))
5377+
},
5378+
)
5379+
5380+
# Return success with the body size
5381+
return web.json_response(
5382+
{
5383+
"method": request.method,
5384+
"received_size": len(body),
5385+
"content_length": request.headers.get("Content-Length"),
5386+
}
5387+
)
5388+
5389+
app = web.Application()
5390+
app.router.add_route(method, "/upload/", handler)
5391+
app.router.add_route(method, "/upload", handler)
5392+
5393+
client = await aiohttp_client(app)
5394+
5395+
# Create a test file
5396+
test_file = tmp_path / f"test_upload_{status}_{method.lower()}.txt"
5397+
content = f"Test {method} file content for {status} redirect.".encode()
5398+
await asyncio.to_thread(test_file.write_bytes, content)
5399+
expected_size = len(content)
5400+
5401+
# Upload file to URL with trailing slash (will trigger redirect)
5402+
f = await asyncio.to_thread(open, test_file, "rb")
5403+
try:
5404+
async with client.request(method, "/upload/", data=f) as resp:
5405+
assert resp.status == 200
5406+
result = await resp.json()
5407+
5408+
# The server should receive the full file content after redirect
5409+
assert result["method"] == method # Method should be preserved
5410+
assert result["received_size"] == expected_size
5411+
assert result["content_length"] == str(expected_size)
5412+
5413+
# Both requests should have received the same content
5414+
assert len(received_bodies) == 2
5415+
assert received_bodies[0] == content # First request
5416+
assert received_bodies[1] == content # After redirect
5417+
finally:
5418+
await asyncio.to_thread(f.close)
5419+
5420+
5421+
async def test_file_upload_307_302_redirect_chain(
5422+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path
5423+
) -> None:
5424+
"""Test that file uploads work correctly with 307->302->200 redirect chain.
5425+
5426+
This verifies that:
5427+
1. 307 preserves POST method and file body
5428+
2. 302 changes POST to GET and drops the body
5429+
3. No body leaks to the final GET request
5430+
"""
5431+
received_requests: list[dict[str, Any]] = []
5432+
5433+
async def handler(request: web.Request) -> web.Response:
5434+
# Store request details
5435+
body = await request.read()
5436+
received_requests.append(
5437+
{
5438+
"path": str(request.url.path),
5439+
"method": request.method,
5440+
"body_size": len(body),
5441+
"content_length": request.headers.get("Content-Length"),
5442+
}
5443+
)
5444+
5445+
if request.url.path == "/upload307":
5446+
# First redirect: 307 should preserve method and body
5447+
return web.Response(status=307, headers={"Location": "/upload302"})
5448+
elif request.url.path == "/upload302":
5449+
# Second redirect: 302 should change POST to GET
5450+
return web.Response(status=302, headers={"Location": "/final"})
5451+
else:
5452+
# Final destination
5453+
return web.json_response(
5454+
{
5455+
"final_method": request.method,
5456+
"final_body_size": len(body),
5457+
"requests_received": len(received_requests),
5458+
}
5459+
)
5460+
5461+
app = web.Application()
5462+
app.router.add_route("*", "/upload307", handler)
5463+
app.router.add_route("*", "/upload302", handler)
5464+
app.router.add_route("*", "/final", handler)
5465+
5466+
client = await aiohttp_client(app)
5467+
5468+
# Create a test file
5469+
test_file = tmp_path / "test_redirect_chain.txt"
5470+
content = b"Test file content that should not leak to GET request"
5471+
await asyncio.to_thread(test_file.write_bytes, content)
5472+
expected_size = len(content)
5473+
5474+
# Upload file to URL that triggers 307->302->final redirect chain
5475+
f = await asyncio.to_thread(open, test_file, "rb")
5476+
try:
5477+
async with client.post("/upload307", data=f) as resp:
5478+
assert resp.status == 200
5479+
result = await resp.json()
5480+
5481+
# Verify the redirect chain
5482+
assert len(resp.history) == 2
5483+
assert resp.history[0].status == 307
5484+
assert resp.history[1].status == 302
5485+
5486+
# Verify final request is GET with no body
5487+
assert result["final_method"] == "GET"
5488+
assert result["final_body_size"] == 0
5489+
assert result["requests_received"] == 3
5490+
5491+
# Verify the request sequence
5492+
assert len(received_requests) == 3
5493+
5494+
# First request (307): POST with full body
5495+
assert received_requests[0]["path"] == "/upload307"
5496+
assert received_requests[0]["method"] == "POST"
5497+
assert received_requests[0]["body_size"] == expected_size
5498+
assert received_requests[0]["content_length"] == str(expected_size)
5499+
5500+
# Second request (302): POST with preserved body from 307
5501+
assert received_requests[1]["path"] == "/upload302"
5502+
assert received_requests[1]["method"] == "POST"
5503+
assert received_requests[1]["body_size"] == expected_size
5504+
assert received_requests[1]["content_length"] == str(expected_size)
5505+
5506+
# Third request (final): GET with no body (302 changed method and dropped body)
5507+
assert received_requests[2]["path"] == "/final"
5508+
assert received_requests[2]["method"] == "GET"
5509+
assert received_requests[2]["body_size"] == 0
5510+
assert received_requests[2]["content_length"] is None
5511+
5512+
finally:
5513+
await asyncio.to_thread(f.close)

tests/test_payload.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,3 +1278,79 @@ def open_file() -> TextIO:
12781278
assert len(writer.buffer) == utf16_file_size
12791279
finally:
12801280
await loop.run_in_executor(None, f.close)
1281+
1282+
1283+
async def test_iobase_payload_size_after_reading(tmp_path: Path) -> None:
1284+
"""Test that IOBasePayload.size returns correct size after file has been read.
1285+
1286+
This demonstrates the bug where size calculation doesn't account for
1287+
the current file position, causing issues with 307/308 redirects.
1288+
"""
1289+
# Create a test file with known content
1290+
test_file = tmp_path / "test.txt"
1291+
content = b"Hello, World! This is test content."
1292+
await asyncio.to_thread(test_file.write_bytes, content)
1293+
expected_size = len(content)
1294+
1295+
# Open the file and create payload
1296+
f = await asyncio.to_thread(open, test_file, "rb")
1297+
try:
1298+
p = payload.BufferedReaderPayload(f)
1299+
1300+
# First size check - should return full file size
1301+
assert p.size == expected_size
1302+
1303+
# Read the file (simulating first request)
1304+
writer = BufferWriter()
1305+
await p.write(writer)
1306+
assert len(writer.buffer) == expected_size
1307+
1308+
# Second size check - should still return full file size
1309+
# but currently returns 0 because file position is at EOF
1310+
assert p.size == expected_size # This assertion fails!
1311+
1312+
# Attempting to write again should write the full content
1313+
# but currently writes nothing because file is at EOF
1314+
writer2 = BufferWriter()
1315+
await p.write(writer2)
1316+
assert len(writer2.buffer) == expected_size # This also fails!
1317+
finally:
1318+
await asyncio.to_thread(f.close)
1319+
1320+
1321+
async def test_iobase_payload_size_unseekable() -> None:
1322+
"""Test that IOBasePayload.size returns None for unseekable files."""
1323+
1324+
class UnseekableFile:
1325+
"""Mock file object that doesn't support seeking."""
1326+
1327+
def __init__(self, content: bytes) -> None:
1328+
self.content = content
1329+
self.pos = 0
1330+
1331+
def read(self, size: int) -> bytes:
1332+
result = self.content[self.pos : self.pos + size]
1333+
self.pos += len(result)
1334+
return result
1335+
1336+
def tell(self) -> int:
1337+
raise OSError("Unseekable file")
1338+
1339+
content = b"Unseekable content"
1340+
f = UnseekableFile(content)
1341+
p = payload.IOBasePayload(f) # type: ignore[arg-type]
1342+
1343+
# Size should return None for unseekable files
1344+
assert p.size is None
1345+
1346+
# Payload should not be consumed before writing
1347+
assert p.consumed is False
1348+
1349+
# Writing should still work
1350+
writer = BufferWriter()
1351+
await p.write(writer)
1352+
assert writer.buffer == content
1353+
1354+
# For unseekable files that can't tell() or seek(),
1355+
# they are marked as consumed after the first write
1356+
assert p.consumed is True

0 commit comments

Comments
 (0)