[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(