fix(uploads): drain an over-cap body before refusing it
Found in the browser UAT, not by the gate. Moving the cap onto the arriving bytes meant it fires while the client is still sending, and answering + closing mid-upload is what XHR reports as a network failure: the upload dialog said "the server couldn't be reached" while the access log showed a correct 422 carrying the limit. A refusal the client cannot read is an outage as far as the user is concerned. `capped_stream_to` now reads the remainder to nowhere before raising, so the client finishes sending and reads the real answer -- re-measured in the browser, which now names the limit. Nothing past the cap is written, and the request is still bounded by bodylimit's ceiling, so the cost is bandwidth an AUTHENTICATED caller was sending anyway. The anonymous path is untouched: auth still resolves before a byte is read. The regression test sits on the helper, not the route, and the reason is worth keeping: the route-level version was written first and PASSED with the drain deleted. httpx's ASGI transport pumps the request body whether or not the app reads it, so "drained" and "abandoned" are indistinguishable through TestClient. Its own mutation check is what exposed it.
This commit is contained in:
@@ -93,13 +93,30 @@ async def capped_stream_to(
|
||||
neither of them a second copy of the loop.
|
||||
|
||||
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
|
||||
arrived before the limit was passed."""
|
||||
arrived before the limit was passed.
|
||||
|
||||
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
|
||||
Answering mid-upload and closing the connection is what the client sees as a network failure,
|
||||
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
|
||||
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
|
||||
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
|
||||
finish sending, read the 422 and say the true thing.
|
||||
|
||||
The bytes are discarded, never written, and the whole request is already bounded by
|
||||
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
|
||||
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
|
||||
size = 0
|
||||
over = False
|
||||
async for chunk in request.stream():
|
||||
size += len(chunk)
|
||||
if over:
|
||||
continue # draining — see the docstring; counted, not stored
|
||||
if size > limit:
|
||||
raise refusal
|
||||
over = True
|
||||
continue
|
||||
out.write(chunk)
|
||||
if over:
|
||||
raise refusal
|
||||
return size
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user