Skip to content

Commit 5d88886

Browse files
authored
Merge pull request #1 from AlePiccin/claude/fix-client-disconnect-handling-st3Fu
2 parents eba8942 + 8e95d0a commit 5d88886

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

fastapi/routing.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
from starlette.concurrency import iterate_in_threadpool, run_in_threadpool
7979
from starlette.datastructures import FormData
8080
from starlette.exceptions import HTTPException
81-
from starlette.requests import Request
81+
from starlette.requests import ClientDisconnect, Request
8282
from starlette.responses import JSONResponse, Response, StreamingResponse
8383
from starlette.routing import (
8484
BaseRoute,
@@ -442,6 +442,8 @@ async def app(request: Request) -> Response:
442442
except HTTPException:
443443
# If a middleware raises an HTTPException, it should be raised again
444444
raise
445+
except ClientDisconnect:
446+
raise
445447
except Exception as e:
446448
http_error = HTTPException(
447449
status_code=400, detail="There was an error parsing the body"

tests/test_client_disconnect.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Test that ClientDisconnect during request body reading is not
3+
misreported as HTTP 400 "There was an error parsing the body".
4+
5+
Ref: https://github.com/fastapi/fastapi/issues/XXXXX
6+
"""
7+
8+
import pytest
9+
from starlette.requests import ClientDisconnect
10+
11+
from fastapi import Body, FastAPI, Form
12+
from fastapi.testclient import TestClient
13+
14+
pytestmark = pytest.mark.anyio
15+
16+
app = FastAPI()
17+
18+
19+
@app.post("/json")
20+
async def json_endpoint(data: dict = Body(...)):
21+
return data
22+
23+
24+
@app.post("/form")
25+
async def form_endpoint(field: str = Form(...)):
26+
return {"field": field}
27+
28+
29+
def _make_scope(path: str) -> dict:
30+
return {
31+
"type": "http",
32+
"asgi": {"version": "3.0"},
33+
"http_version": "1.1",
34+
"method": "POST",
35+
"scheme": "http",
36+
"path": path,
37+
"raw_path": path.encode(),
38+
"query_string": b"",
39+
"headers": [(b"content-type", b"application/json")],
40+
"client": ("127.0.0.1", 12345),
41+
"server": ("127.0.0.1", 8000),
42+
}
43+
44+
45+
async def test_client_disconnect_json_body():
46+
"""ClientDisconnect during JSON body reading must not become HTTP 400."""
47+
messages = [
48+
{"type": "http.request", "body": b'{"incomplete": ', "more_body": True},
49+
{"type": "http.disconnect"},
50+
]
51+
52+
async def receive():
53+
return messages.pop(0)
54+
55+
sent: list[dict] = []
56+
57+
async def send(message):
58+
sent.append(message)
59+
60+
scope = _make_scope("/json")
61+
with pytest.raises(ClientDisconnect):
62+
await app(scope, receive, send)
63+
64+
# Ensure no HTTP 400 response was sent
65+
for msg in sent:
66+
if msg.get("type") == "http.response.start":
67+
assert msg.get("status") != 400, (
68+
"ClientDisconnect should not produce HTTP 400"
69+
)
70+
71+
72+
async def test_client_disconnect_form_body():
73+
"""ClientDisconnect during form body reading must not become HTTP 400."""
74+
messages = [
75+
{
76+
"type": "http.request",
77+
"body": b"field=partial",
78+
"more_body": True,
79+
},
80+
{"type": "http.disconnect"},
81+
]
82+
83+
async def receive():
84+
return messages.pop(0)
85+
86+
sent: list[dict] = []
87+
88+
async def send(message):
89+
sent.append(message)
90+
91+
scope = _make_scope("/form")
92+
scope["headers"] = [
93+
(b"content-type", b"application/x-www-form-urlencoded"),
94+
]
95+
with pytest.raises(ClientDisconnect):
96+
await app(scope, receive, send)
97+
98+
for msg in sent:
99+
if msg.get("type") == "http.response.start":
100+
assert msg.get("status") != 400, (
101+
"ClientDisconnect should not produce HTTP 400"
102+
)
103+
104+
105+
def test_body_parse_error_still_returns_400():
106+
"""Generic body parse errors must still produce HTTP 400."""
107+
client = TestClient(app, raise_server_exceptions=False)
108+
response = client.post(
109+
"/json",
110+
content=b"not valid json",
111+
headers={"content-type": "application/json"},
112+
)
113+
assert response.status_code == 422

0 commit comments

Comments
 (0)