[Bugfix] Return 4xx for client-caused errors in /detokenize (#52622)
Signed-off-by: rajathpi <rajathpai2000@gmail.com>
Rajath Pai committed
Aug 18, 2026 at 09:07 UTC
b01728b0880ca419bb41199523535457f4ab0010
2 files changed
+115
-10
tests/entrypoints/serve/tokenize/test_serving_tokenization.py
+113
@@ -1,20 +1,27 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from argparse import Namespace
5
from dataclasses import dataclass, field
6
+from http import HTTPStatus
7
from typing import Any
8
from unittest.mock import AsyncMock, MagicMock
9
10
import pytest
11
+from fastapi import FastAPI
12
+from fastapi.testclient import TestClient
13
14
from vllm.config.multimodal import MultiModalConfig
15
from vllm.entrypoints.openai.models.protocol import BaseModelPath
16
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
17
+from vllm.entrypoints.serve.exception_handling.register import init_exception_handler
18
+from vllm.entrypoints.serve.tokenize.api_router import attach_router
19
from vllm.entrypoints.serve.tokenize.protocol import (
20
TokenizeChatRequest,
21
TokenizeCompletionRequest,
22
)
23
from vllm.entrypoints.serve.tokenize.serving import ServingTokenization
24
+from vllm.exceptions import VLLMNotFoundError, VLLMValidationError
25
from vllm.renderers.online_renderer import OnlineRenderer
26
from vllm.v1.engine.async_llm import AsyncLLM
27
@@ -133,3 +140,109 @@ async def test_tokenize_completion_skips_mm_cache_for_renderer_only_path():
140
serving.online_renderer.preprocess_completion.call_args.kwargs["skip_mm_cache"]
141
is True
142
)
143
+
144
+
145
+class TestDetokenizeClientErrorResponses:
146
+ """Client-caused errors from /detokenize are 4xx, not 500.
147
+
148
+ /detokenize used to wrap ``create_detokenize`` in a blanket
149
+ ``except Exception`` that reported every failure as 500, while its
150
+ sibling /tokenize let exceptions propagate to the global exception
151
+ handlers, which map client-caused errors to 4xx and keep genuine
152
+ server errors at 500. These tests pin the shared contract for both
153
+ endpoints. See #52246 for the same fix in the Anthropic entrypoint.
154
+ """
155
+
156
+ @staticmethod
157
+ def _make_api_app(handler: MagicMock) -> FastAPI:
158
+ app = FastAPI()
159
+ app.state.args = Namespace(log_error_stack=False)
160
+ app.state.serving_tokenization = handler
161
+ attach_router(app)
162
+ init_exception_handler(app)
163
+ return app
164
+
165
+ def _post(self, handler: MagicMock, path: str = "/detokenize"):
166
+ app = self._make_api_app(handler)
167
+ body: dict[str, Any] = {"model": "test-model"}
168
+ if path == "/detokenize":
169
+ body["tokens"] = [1, 2, 3]
170
+ else:
171
+ body["prompt"] = "Hello"
172
+ with TestClient(app, raise_server_exceptions=False) as client:
173
+ return client.post(path, json=body)
174
+
175
+ def test_vllm_validation_error_returns_bad_request(self):
176
+ handler = MagicMock(spec=ServingTokenization)
177
+ handler.create_detokenize.side_effect = VLLMValidationError(
178
+ "invalid token ids", parameter="tokens"
179
+ )
180
+
181
+ response = self._post(handler)
182
+
183
+ assert response.status_code == HTTPStatus.BAD_REQUEST
184
+ error = response.json()["error"]
185
+ assert error["type"] == "BadRequestError"
186
+ assert error["param"] == "tokens"
187
+
188
+ def test_vllm_not_found_error_returns_not_found(self):
189
+ handler = MagicMock(spec=ServingTokenization)
190
+ handler.create_detokenize.side_effect = VLLMNotFoundError(
191
+ "LoRA adapter nonexistent-lora not found"
192
+ )
193
+
194
+ response = self._post(handler)
195
+
196
+ assert response.status_code == HTTPStatus.NOT_FOUND
197
+ assert response.json()["error"]["type"] == "NotFoundError"
198
+
199
+ def test_value_error_returns_bad_request(self):
200
+ handler = MagicMock(spec=ServingTokenization)
201
+ handler.create_detokenize.side_effect = ValueError("bad input")
202
+
203
+ response = self._post(handler)
204
+
205
+ assert response.status_code == HTTPStatus.BAD_REQUEST
206
+ assert response.json()["error"]["type"] == "BadRequestError"
207
+
208
+ def test_overflow_error_returns_bad_request(self):
209
+ """Fast tokenizers raise OverflowError for out-of-range token ids.
210
+
211
+ This used to be special-cased into a RequestValidationError; the
212
+ global OverflowError handler keeps the same 400 status code.
213
+ """
214
+ handler = MagicMock(spec=ServingTokenization)
215
+ handler.create_detokenize.side_effect = OverflowError(
216
+ "out of range integral type conversion attempted"
217
+ )
218
+
219
+ response = self._post(handler)
220
+
221
+ assert response.status_code == HTTPStatus.BAD_REQUEST
222
+ assert response.json()["error"]["type"] == "BadRequestError"
223
+
224
+ def test_server_error_still_returns_internal_server_error(self):
225
+ """Genuine server bugs keep the existing 500 behaviour."""
226
+ handler = MagicMock(spec=ServingTokenization)
227
+ handler.create_detokenize.side_effect = RuntimeError("boom")
228
+
229
+ response = self._post(handler)
230
+
231
+ assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
232
+ assert response.json()["error"]["type"] == "InternalServerError"
233
+
234
+ def test_tokenize_and_detokenize_agree_on_client_errors(self):
235
+ """The two sibling endpoints report the same client error alike."""
236
+ statuses = {}
237
+ for path, method in (
238
+ ("/tokenize", "create_tokenize"),
239
+ ("/detokenize", "create_detokenize"),
240
+ ):
241
+ handler = MagicMock(spec=ServingTokenization)
242
+ getattr(handler, method).side_effect = VLLMValidationError(
243
+ "invalid input", parameter="model"
244
+ )
245
+ statuses[path] = self._post(handler, path=path).status_code
246
+
247
+ assert statuses["/tokenize"] == HTTPStatus.BAD_REQUEST
248
+ assert statuses["/detokenize"] == HTTPStatus.BAD_REQUEST
vllm/entrypoints/serve/tokenize/api_router.py
+2
-10
@@ -4,8 +4,7 @@
4
5
from http import HTTPStatus
6
7
-from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
8
-from fastapi.exceptions import RequestValidationError
7
+from fastapi import APIRouter, Depends, FastAPI, Request
8
from fastapi.responses import JSONResponse
9
from typing_extensions import assert_never
10
@@ -72,14 +71,7 @@ async def tokenize(request: TokenizeRequest, raw_request: Request):
71
async def detokenize(request: DetokenizeRequest, raw_request: Request):
72
handler = tokenization(raw_request)
73
75
- try:
76
- generator = await handler.create_detokenize(request, raw_request)
77
- except OverflowError as e:
78
- raise RequestValidationError(errors=[str(e)]) from e
79
- except Exception as e:
80
- raise HTTPException(
81
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=str(e)
82
- ) from e
74
+ generator = await handler.create_detokenize(request, raw_request)
75
76
if isinstance(generator, ErrorResponse):
77
return JSONResponse(