[Bugfix][Frontend] Return 400 for malformed RL dev route bodies (#59236)
Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
aoshen02 committed
Sep 30, 2026 at 00:46 UTC
cd94b21264a76c484a567566658995fd3a84db65
3 files changed
+115
-23
tests/entrypoints/unit_tests/test_dev_route_request_bodies.py
new
+96
@@ -0,0 +1,96 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Malformed bodies on RL dev routes are 400s that never reach the engine."""
4
+
5
+import json
6
+from types import SimpleNamespace
7
+
8
+import pytest
9
+from fastapi import FastAPI
10
+from fastapi.testclient import TestClient
11
+
12
+from vllm.entrypoints.serve.dev.rlhf.api_router import router as rlhf_router
13
+from vllm.entrypoints.serve.dev.rpc.api_router import router as rpc_router
14
+from vllm.entrypoints.serve.exception_handling.register import init_exception_handler
15
+
16
+pytestmark = pytest.mark.cpu_test
17
+
18
+
19
+class Engine:
20
+ def __init__(self):
21
+ self.calls: list[str] = []
22
+
23
+ def __getattr__(self, name):
24
+ async def call(*args, **kwargs):
25
+ self.calls.append(name)
26
+
27
+ return call
28
+
29
+
30
+@pytest.fixture
31
+def client():
32
+ app = FastAPI()
33
+ app.include_router(rlhf_router)
34
+ app.include_router(rpc_router)
35
+ init_exception_handler(app)
36
+ app.state.engine_client = Engine()
37
+ app.state.args = SimpleNamespace(log_error_stack=False)
38
+ return TestClient(app)
39
+
40
+
41
+def post(client, path, body):
42
+ return client.post(
43
+ path, content=json.dumps(body), headers={"content-type": "application/json"}
44
+ )
45
+
46
+
47
+@pytest.mark.parametrize(
48
+ "path",
49
+ [
50
+ "/abort_requests",
51
+ "/init_weight_transfer_engine",
52
+ "/update_weights",
53
+ "/collective_rpc",
54
+ ],
55
+)
56
+@pytest.mark.parametrize("body", [[], 1, "x", None])
57
+def test_non_object_body_is_rejected(client, path, body):
58
+ assert post(client, path, body).status_code == 400
59
+ assert client.app.state.engine_client.calls == []
60
+
61
+
62
+@pytest.mark.parametrize(
63
+ "path,body",
64
+ [
65
+ ("/init_weight_transfer_engine", {}),
66
+ ("/init_weight_transfer_engine", {"init_info": []}),
67
+ ("/init_weight_transfer_engine", {"init_info": 1}),
68
+ ("/update_weights", {}),
69
+ ("/update_weights", {"update_info": 1}),
70
+ ("/update_weights", {"update_info": [1, 2]}),
71
+ ("/update_weights", {"update_info": "x"}),
72
+ ],
73
+)
74
+def test_invalid_field_is_rejected_before_the_engine(client, path, body):
75
+ # Reaching the engine would also abort an in-progress weight update.
76
+ assert post(client, path, body).status_code == 400
77
+ assert client.app.state.engine_client.calls == []
78
+
79
+
80
+@pytest.mark.parametrize(
81
+ "path,body,engine_call",
82
+ [
83
+ ("/abort_requests", {"request_ids": ["a"]}, "abort"),
84
+ (
85
+ "/init_weight_transfer_engine",
86
+ {"init_info": {}},
87
+ "init_weight_transfer_engine",
88
+ ),
89
+ ("/update_weights", {"update_info": {"names": []}}, "update_weights"),
90
+ ("/update_weights", {"update_info": [{}, {}]}, "update_weights"),
91
+ ("/collective_rpc", {"method": "m"}, "collective_rpc"),
92
+ ],
93
+)
94
+def test_valid_body_reaches_the_engine(client, path, body, engine_call):
95
+ assert post(client, path, body).status_code == 200
96
+ assert client.app.state.engine_client.calls == [engine_call]
vllm/entrypoints/serve/dev/rlhf/api_router.py
+14
-23
@@ -3,13 +3,14 @@
3
4
import json
5
from http import HTTPStatus
6
-from typing import Annotated
6
+from typing import Annotated, Any
7
8
from fastapi import APIRouter, Body, FastAPI, HTTPException, Query, Request
9
from fastapi.responses import JSONResponse
10
11
from vllm.distributed.weight_transfer.base import (
12
WeightTransferInitRequest,
13
+ WeightTransferUpdatePayload,
14
WeightTransferUpdateRequest,
15
)
16
from vllm.engine.protocol import EngineClient
@@ -104,6 +105,10 @@ async def abort_requests(raw_request: Request) -> JSONResponse:
105
body = await raw_request.json()
106
except json.JSONDecodeError as e:
107
raise HTTPException(status_code=400, detail="Invalid JSON format") from e # noqa: B904
108
+ if not isinstance(body, dict):
109
+ raise HTTPException(
110
+ status_code=400, detail="Request body must be a JSON object"
111
+ )
112
113
request_ids = body.get("request_ids")
114
@@ -154,17 +159,10 @@ async def is_paused(raw_request: Request) -> JSONResponse:
159
160
161
@router.post("/init_weight_transfer_engine")
157
-async def init_weight_transfer_engine(raw_request: Request):
158
- try:
159
- body = await raw_request.json()
160
- except json.JSONDecodeError as e:
161
- raise HTTPException(status_code=400, detail="Invalid JSON format") from e # noqa: B904
162
- init_info = body.get("init_info")
163
- if init_info is None:
164
- raise HTTPException(
165
- status_code=HTTPStatus.BAD_REQUEST.value,
166
- detail="Missing 'init_info' in request body",
167
- )
162
+async def init_weight_transfer_engine(
163
+ raw_request: Request,
164
+ init_info: Annotated[dict[str, Any], Body(embed=True)],
165
+):
166
await engine_client(raw_request).init_weight_transfer_engine(
167
WeightTransferInitRequest(init_info=init_info)
168
)
@@ -184,17 +182,10 @@ async def start_draft_weight_update(raw_request: Request):
182
183
184
@router.post("/update_weights")
187
-async def update_weights(raw_request: Request):
188
- try:
189
- body = await raw_request.json()
190
- except json.JSONDecodeError as e:
191
- raise HTTPException(status_code=400, detail="Invalid JSON format") from e # noqa: B904
192
- update_info = body.get("update_info")
193
- if update_info is None:
194
- raise HTTPException(
195
- status_code=HTTPStatus.BAD_REQUEST.value,
196
- detail="Missing 'update_info' in request body",
197
- )
185
+async def update_weights(
186
+ raw_request: Request,
187
+ update_info: Annotated[WeightTransferUpdatePayload, Body(embed=True)],
188
+):
189
await engine_client(raw_request).update_weights(
190
request=WeightTransferUpdateRequest(update_info=update_info)
191
)
vllm/entrypoints/serve/dev/rpc/api_router.py
+5
@@ -29,6 +29,11 @@ async def collective_rpc(raw_request: Request):
29
status_code=HTTPStatus.BAD_REQUEST.value,
30
detail=f"JSON decode error: {e}",
31
) from e
32
+ if not isinstance(body, dict):
33
+ raise HTTPException(
34
+ status_code=HTTPStatus.BAD_REQUEST.value,
35
+ detail="Request body must be a JSON object",
36
+ )
37
method = body.get("method")
38
if method is None:
39
raise HTTPException(