Skip to content

Commit 16703bb

Browse files
authored
Fix file uploads failing with HTTP 422 on 307/308 redirects (#11290)
1 parent 57983b4 commit 16703bb

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
@@ -761,6 +761,12 @@ async def _connect_and_send_request(
761761
data = None
762762
if headers.get(hdrs.CONTENT_LENGTH):
763763
headers.pop(hdrs.CONTENT_LENGTH)
764+
else:
765+
# For 307/308, always preserve the request body
766+
# For 301/302 with non-POST methods, preserve the request body
767+
# https://www.rfc-editor.org/rfc/rfc9110#section-15.4.3-3.1
768+
# Use the existing payload to avoid recreating it from a potentially consumed file
769+
data = req._body
764770

765771
r_url = resp.headers.get(hdrs.LOCATION) or resp.headers.get(
766772
hdrs.URI

aiohttp/payload.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -484,10 +484,14 @@ def _set_or_restore_start_position(self) -> None:
484484
if self._start_position is None:
485485
try:
486486
self._start_position = self._value.tell()
487-
except OSError:
487+
except (OSError, AttributeError):
488488
self._consumed = True # Cannot seek, mark as consumed
489489
return
490-
self._value.seek(self._start_position)
490+
try:
491+
self._value.seek(self._start_position)
492+
except (OSError, AttributeError):
493+
# Failed to seek back - mark as consumed since we've already read
494+
self._consumed = True
491495

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

tests/test_client_functional.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5357,3 +5357,228 @@ async def handler(request: web.Request) -> web.Response:
53575357
assert (
53585358
len(resp._raw_cookie_headers) == 12
53595359
), "All raw headers should be preserved"
5360+
5361+
5362+
@pytest.mark.parametrize("status", (307, 308))
5363+
async def test_file_upload_307_308_redirect(
5364+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path, status: int
5365+
) -> None:
5366+
"""Test that file uploads work correctly with 307/308 redirects.
5367+
5368+
This demonstrates the bug where file payloads get incorrect Content-Length
5369+
on redirect because the file position isn't reset.
5370+
"""
5371+
received_bodies: List[bytes] = []
5372+
5373+
async def handler(request: web.Request) -> web.Response:
5374+
# Store the body content
5375+
body = await request.read()
5376+
received_bodies.append(body)
5377+
5378+
if str(request.url.path).endswith("/"):
5379+
# Redirect URLs ending with / to remove the trailing slash
5380+
return web.Response(
5381+
status=status,
5382+
headers={
5383+
"Location": str(request.url.with_path(request.url.path.rstrip("/")))
5384+
},
5385+
)
5386+
5387+
# Return success with the body size
5388+
return web.json_response(
5389+
{
5390+
"received_size": len(body),
5391+
"content_length": request.headers.get("Content-Length"),
5392+
}
5393+
)
5394+
5395+
app = web.Application()
5396+
app.router.add_post("/upload/", handler)
5397+
app.router.add_post("/upload", handler)
5398+
5399+
client = await aiohttp_client(app)
5400+
5401+
# Create a test file
5402+
test_file = tmp_path / f"test_upload_{status}.txt"
5403+
content = b"This is test file content for upload."
5404+
await asyncio.to_thread(test_file.write_bytes, content)
5405+
expected_size = len(content)
5406+
5407+
# Upload file to URL with trailing slash (will trigger redirect)
5408+
f = await asyncio.to_thread(open, test_file, "rb")
5409+
try:
5410+
async with client.post("/upload/", data=f) as resp:
5411+
assert resp.status == 200
5412+
result = await resp.json()
5413+
5414+
# The server should receive the full file content
5415+
assert result["received_size"] == expected_size
5416+
assert result["content_length"] == str(expected_size)
5417+
5418+
# Both requests should have received the same content
5419+
assert len(received_bodies) == 2
5420+
assert received_bodies[0] == content # First request
5421+
assert received_bodies[1] == content # After redirect
5422+
finally:
5423+
await asyncio.to_thread(f.close)
5424+
5425+
5426+
@pytest.mark.parametrize("status", [301, 302])
5427+
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
5428+
async def test_file_upload_301_302_redirect_non_post(
5429+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path, status: int, method: str
5430+
) -> None:
5431+
"""Test that file uploads work correctly with 301/302 redirects for non-POST methods.
5432+
5433+
Per RFC 9110, 301/302 redirects should preserve the method and body for non-POST requests.
5434+
"""
5435+
received_bodies: List[bytes] = []
5436+
5437+
async def handler(request: web.Request) -> web.Response:
5438+
# Store the body content
5439+
body = await request.read()
5440+
received_bodies.append(body)
5441+
5442+
if str(request.url.path).endswith("/"):
5443+
# Redirect URLs ending with / to remove the trailing slash
5444+
return web.Response(
5445+
status=status,
5446+
headers={
5447+
"Location": str(request.url.with_path(request.url.path.rstrip("/")))
5448+
},
5449+
)
5450+
5451+
# Return success with the body size
5452+
return web.json_response(
5453+
{
5454+
"method": request.method,
5455+
"received_size": len(body),
5456+
"content_length": request.headers.get("Content-Length"),
5457+
}
5458+
)
5459+
5460+
app = web.Application()
5461+
app.router.add_route(method, "/upload/", handler)
5462+
app.router.add_route(method, "/upload", handler)
5463+
5464+
client = await aiohttp_client(app)
5465+
5466+
# Create a test file
5467+
test_file = tmp_path / f"test_upload_{status}_{method.lower()}.txt"
5468+
content = f"Test {method} file content for {status} redirect.".encode()
5469+
await asyncio.to_thread(test_file.write_bytes, content)
5470+
expected_size = len(content)
5471+
5472+
# Upload file to URL with trailing slash (will trigger redirect)
5473+
f = await asyncio.to_thread(open, test_file, "rb")
5474+
try:
5475+
async with client.request(method, "/upload/", data=f) as resp:
5476+
assert resp.status == 200
5477+
result = await resp.json()
5478+
5479+
# The server should receive the full file content after redirect
5480+
assert result["method"] == method # Method should be preserved
5481+
assert result["received_size"] == expected_size
5482+
assert result["content_length"] == str(expected_size)
5483+
5484+
# Both requests should have received the same content
5485+
assert len(received_bodies) == 2
5486+
assert received_bodies[0] == content # First request
5487+
assert received_bodies[1] == content # After redirect
5488+
finally:
5489+
await asyncio.to_thread(f.close)
5490+
5491+
5492+
async def test_file_upload_307_302_redirect_chain(
5493+
aiohttp_client: AiohttpClient, tmp_path: pathlib.Path
5494+
) -> None:
5495+
"""Test that file uploads work correctly with 307->302->200 redirect chain.
5496+
5497+
This verifies that:
5498+
1. 307 preserves POST method and file body
5499+
2. 302 changes POST to GET and drops the body
5500+
3. No body leaks to the final GET request
5501+
"""
5502+
received_requests: List[Dict[str, Any]] = []
5503+
5504+
async def handler(request: web.Request) -> web.Response:
5505+
# Store request details
5506+
body = await request.read()
5507+
received_requests.append(
5508+
{
5509+
"path": str(request.url.path),
5510+
"method": request.method,
5511+
"body_size": len(body),
5512+
"content_length": request.headers.get("Content-Length"),
5513+
}
5514+
)
5515+
5516+
if request.url.path == "/upload307":
5517+
# First redirect: 307 should preserve method and body
5518+
return web.Response(status=307, headers={"Location": "/upload302"})
5519+
elif request.url.path == "/upload302":
5520+
# Second redirect: 302 should change POST to GET
5521+
return web.Response(status=302, headers={"Location": "/final"})
5522+
else:
5523+
# Final destination
5524+
return web.json_response(
5525+
{
5526+
"final_method": request.method,
5527+
"final_body_size": len(body),
5528+
"requests_received": len(received_requests),
5529+
}
5530+
)
5531+
5532+
app = web.Application()
5533+
app.router.add_route("*", "/upload307", handler)
5534+
app.router.add_route("*", "/upload302", handler)
5535+
app.router.add_route("*", "/final", handler)
5536+
5537+
client = await aiohttp_client(app)
5538+
5539+
# Create a test file
5540+
test_file = tmp_path / "test_redirect_chain.txt"
5541+
content = b"Test file content that should not leak to GET request"
5542+
await asyncio.to_thread(test_file.write_bytes, content)
5543+
expected_size = len(content)
5544+
5545+
# Upload file to URL that triggers 307->302->final redirect chain
5546+
f = await asyncio.to_thread(open, test_file, "rb")
5547+
try:
5548+
async with client.post("/upload307", data=f) as resp:
5549+
assert resp.status == 200
5550+
result = await resp.json()
5551+
5552+
# Verify the redirect chain
5553+
assert len(resp.history) == 2
5554+
assert resp.history[0].status == 307
5555+
assert resp.history[1].status == 302
5556+
5557+
# Verify final request is GET with no body
5558+
assert result["final_method"] == "GET"
5559+
assert result["final_body_size"] == 0
5560+
assert result["requests_received"] == 3
5561+
5562+
# Verify the request sequence
5563+
assert len(received_requests) == 3
5564+
5565+
# First request (307): POST with full body
5566+
assert received_requests[0]["path"] == "/upload307"
5567+
assert received_requests[0]["method"] == "POST"
5568+
assert received_requests[0]["body_size"] == expected_size
5569+
assert received_requests[0]["content_length"] == str(expected_size)
5570+
5571+
# Second request (302): POST with preserved body from 307
5572+
assert received_requests[1]["path"] == "/upload302"
5573+
assert received_requests[1]["method"] == "POST"
5574+
assert received_requests[1]["body_size"] == expected_size
5575+
assert received_requests[1]["content_length"] == str(expected_size)
5576+
5577+
# Third request (final): GET with no body (302 changed method and dropped body)
5578+
assert received_requests[2]["path"] == "/final"
5579+
assert received_requests[2]["method"] == "GET"
5580+
assert received_requests[2]["body_size"] == 0
5581+
assert received_requests[2]["content_length"] is None
5582+
5583+
finally:
5584+
await asyncio.to_thread(f.close)

tests/test_payload.py

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

0 commit comments

Comments
 (0)