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