[Core] Add `max_num_queued_reqs` and `max_num_queued_tokens` for queue size management (#49445)

Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>

Nicolò Lucchesi committed Aug 31, 2026 at 16:26 UTC dafbef15a1c879c64ebb99427917e4ca8d5bca1e
18 files changed +644 -35
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+33 -1
@@ -42,7 +42,7 @@ from vllm.entrypoints.openai.models.serving import (
42 )
43 from vllm.entrypoints.openai.parser.harmony_utils import get_encoding
44 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
45 -from vllm.exceptions import VLLMValidationError
45 +from vllm.exceptions import QueueOverflowError, VLLMValidationError
46 from vllm.inputs import TokensPrompt
47 from vllm.multimodal.inputs import PlaceholderRange
48 from vllm.outputs import CompletionOutput, RequestOutput
@@ -848,6 +848,9 @@ class MockEngine:
848 renderer: MagicMock = field(default_factory=MagicMock)
849 errored: bool = False
850
851 + def check_admission(self, n: int = 1, request_id: str | None = None) -> None:
852 + pass
853 +
854
855 async def _async_serving_chat_init():
856 engine = MockEngine()
@@ -944,6 +947,35 @@ async def test_serving_chat_returns_correct_model_name():
947 assert await serving_chat.create_chat_completion(req) == MODEL_NAME
948
949
950 +@pytest.mark.asyncio
951 +@pytest.mark.parametrize("stream", [True, False])
952 +async def test_admission_rejection_escapes_before_response_starts(stream):
953 + """Overload rejections must propagate out of create_chat_completion.
954 +
955 + For streaming this is the only chance to return a real HTTP status: once
956 + StreamingResponse is constructed the 200 has already been sent and the
957 + rejection would degrade into an in-band SSE error chunk.
958 + """
959 + mock_engine = MagicMock(spec=AsyncLLM)
960 + mock_engine.errored = False
961 + mock_engine.model_config = MockModelConfig()
962 + mock_engine.input_processor = MagicMock()
963 + mock_engine.renderer = _build_renderer(mock_engine.model_config)
964 + mock_engine.check_admission.side_effect = QueueOverflowError()
965 +
966 + serving_chat = _build_serving_chat(mock_engine)
967 + req = ChatCompletionRequest(
968 + model=MODEL_NAME,
969 + messages=[{"role": "user", "content": "what is 1+1?"}],
970 + stream=stream,
971 + )
972 +
973 + with pytest.raises(QueueOverflowError):
974 + await serving_chat.create_chat_completion(req)
975 +
976 + mock_engine.generate.assert_not_called()
977 +
978 +
979 @pytest.mark.asyncio
980 async def test_serving_chat_should_set_correct_max_tokens():
981 mock_engine = MagicMock(spec=AsyncLLM)
tests/entrypoints/speech_to_text/test_speech_to_text_cancellation.py
+2
@@ -38,6 +38,7 @@ async def test_non_streaming_cancel_aborts_engine_requests(
38 ):
39 engine_client = SimpleNamespace(
40 errored=False,
41 + check_admission=Mock(),
42 generate=Mock(side_effect=lambda *_args, **_kwargs: _never_finishes()),
43 abort=AsyncMock(),
44 is_tracing_enabled=AsyncMock(return_value=False),
@@ -104,6 +105,7 @@ async def test_non_streaming_cancel_advances_all_chunk_generators():
105 started_request_ids: list[str] = []
106 engine_client = SimpleNamespace(
107 errored=False,
108 + check_admission=Mock(),
109 generate=Mock(
110 side_effect=lambda *_args, **_kwargs: _records_start_then_never_finishes(
111 started_request_ids, _args[2]
tests/v1/engine/test_admission_control.py new
+384
@@ -0,0 +1,384 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""Unit tests for admission control (max_num_queued_reqs / max_num_queued_tokens).
5 +
6 +These tests cover:
7 +- OutputProcessor.get_num_queued_tokens() token counting
8 +- AsyncLLM.check_admission() admission control logic
9 +- Exception classes (GracefulHTTPError, QueueOverflowError, MaxQueuedTokensError)
10 +- create_error_response() mapping GracefulHTTPError to HTTP 503
11 +- SchedulerConfig field defaults and validation
12 +- human_readable_int CLI notation for max_num_queued_tokens
13 +"""
14 +
15 +import argparse
16 +import asyncio
17 +from http import HTTPStatus
18 +from types import SimpleNamespace
19 +from unittest.mock import AsyncMock, MagicMock
20 +
21 +import pytest
22 +from pydantic import ValidationError
23 +
24 +from vllm.config.scheduler import SchedulerConfig
25 +from vllm.entrypoints.serve.exception_handling.error_response import (
26 + create_error_response,
27 +)
28 +from vllm.exceptions import (
29 + GracefulHTTPError,
30 + MaxQueuedTokensError,
31 + QueueOverflowError,
32 + VLLMError,
33 +)
34 +from vllm.pooling_params import PoolingParams
35 +from vllm.sampling_params import SamplingParams
36 +from vllm.utils.argparse_utils import human_readable_int
37 +from vllm.v1.engine.async_llm import AsyncLLM
38 +from vllm.v1.engine.output_processor import OutputProcessor
39 +
40 +pytestmark = pytest.mark.cpu_test
41 +
42 +# ---------------------------------------------------------------------------
43 +# Helpers
44 +# ---------------------------------------------------------------------------
45 +
46 +
47 +def _make_req_state(prompt_len: int, is_prefilling: bool = True):
48 + return SimpleNamespace(
49 + prompt_len=prompt_len,
50 + is_prefilling=is_prefilling,
51 + )
52 +
53 +
54 +def _make_async_llm(
55 + max_num_queued_reqs: int | None = None,
56 + max_num_queued_tokens: int | None = None,
57 + num_unfinished: int = 0,
58 + num_queued_tokens: int = 0,
59 +) -> AsyncLLM:
60 + """Create a bare AsyncLLM with just the attributes needed for scheduling."""
61 + llm = AsyncLLM.__new__(AsyncLLM)
62 + llm.scheduler_config = SimpleNamespace(
63 + max_num_queued_reqs=max_num_queued_reqs,
64 + max_num_queued_tokens=max_num_queued_tokens,
65 + )
66 + llm.output_processor = MagicMock()
67 + llm.output_processor.get_num_unfinished_requests.return_value = num_unfinished
68 + llm.output_processor.get_num_queued_tokens.return_value = num_queued_tokens
69 + return llm
70 +
71 +
72 +def _make_output_processor(**request_states) -> OutputProcessor:
73 + op = OutputProcessor.__new__(OutputProcessor)
74 + op.request_states = request_states
75 + return op
76 +
77 +
78 +def _make_scheduler_config(**kwargs) -> SchedulerConfig:
79 + return SchedulerConfig(
80 + runner_type="generate",
81 + max_model_len=4096,
82 + is_encoder_decoder=False,
83 + **kwargs,
84 + )
85 +
86 +
87 +# ---------------------------------------------------------------------------
88 +# Exception classes
89 +# ---------------------------------------------------------------------------
90 +
91 +
92 +def test_graceful_http_error_carries_status_and_message():
93 + err = GracefulHTTPError("custom message", HTTPStatus.SERVICE_UNAVAILABLE)
94 + assert err.message == "custom message"
95 + assert err.http_status == HTTPStatus.SERVICE_UNAVAILABLE
96 + assert str(err) == "custom message"
97 +
98 +
99 +def test_graceful_http_error_is_vllm_error():
100 + err = GracefulHTTPError("msg", HTTPStatus.TOO_MANY_REQUESTS)
101 + assert isinstance(err, VLLMError)
102 +
103 +
104 +def test_queue_overflow_error():
105 + err = QueueOverflowError()
106 + assert err.http_status == HTTPStatus.SERVICE_UNAVAILABLE
107 + assert isinstance(err, GracefulHTTPError)
108 + assert "busy" in err.message.lower() or "try again" in err.message.lower()
109 +
110 +
111 +def test_max_queued_tokens_error():
112 + err = MaxQueuedTokensError()
113 + assert err.http_status == HTTPStatus.SERVICE_UNAVAILABLE
114 + assert isinstance(err, GracefulHTTPError)
115 + assert "backlog" in err.message.lower() or "try again" in err.message.lower()
116 +
117 +
118 +@pytest.mark.parametrize("exc_cls", [QueueOverflowError, MaxQueuedTokensError])
119 +def test_admission_exceptions_are_vllm_errors(exc_cls):
120 + """Admission rejections must reach the VLLMError HTTP handler."""
121 + assert issubclass(exc_cls, VLLMError)
122 +
123 +
124 +# ---------------------------------------------------------------------------
125 +# OutputProcessor.get_num_queued_tokens
126 +# ---------------------------------------------------------------------------
127 +
128 +
129 +def test_queued_tokens_empty():
130 + assert _make_output_processor().get_num_queued_tokens() == 0
131 +
132 +
133 +def test_queued_tokens_sums_prefilling_requests():
134 + op = _make_output_processor(r1=_make_req_state(100), r2=_make_req_state(200))
135 + assert op.get_num_queued_tokens() == 300
136 +
137 +
138 +def test_queued_tokens_excludes_non_prefilling():
139 + op = _make_output_processor(
140 + r1=_make_req_state(100, is_prefilling=True),
141 + r2=_make_req_state(200, is_prefilling=False),
142 + r3=_make_req_state(50, is_prefilling=True),
143 + )
144 + assert op.get_num_queued_tokens() == 150
145 +
146 +
147 +def test_queued_tokens_all_non_prefilling():
148 + op = _make_output_processor(
149 + r1=_make_req_state(100, is_prefilling=False),
150 + r2=_make_req_state(200, is_prefilling=False),
151 + )
152 + assert op.get_num_queued_tokens() == 0
153 +
154 +
155 +# ---------------------------------------------------------------------------
156 +# AsyncLLM.check_admission
157 +# ---------------------------------------------------------------------------
158 +
159 +
160 +def test_admission_no_limits_allows_everything():
161 + llm = _make_async_llm(num_unfinished=999, num_queued_tokens=999)
162 + llm.check_admission()
163 +
164 +
165 +# -- max_num_queued_reqs ----------------------------------------------------
166 +
167 +
168 +def test_admission_reqs_allows_when_under_limit():
169 + llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=5)
170 + llm.check_admission()
171 +
172 +
173 +def test_admission_reqs_rejects_at_limit():
174 + llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=10)
175 + with pytest.raises(QueueOverflowError):
176 + llm.check_admission()
177 +
178 +
179 +def test_admission_reqs_rejects_with_n():
180 + llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=8)
181 + with pytest.raises(QueueOverflowError):
182 + llm.check_admission(3)
183 +
184 +
185 +def test_admission_reqs_allows_n_at_boundary():
186 + llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=7)
187 + llm.check_admission(3)
188 +
189 +
190 +def test_admission_reqs_rejects_when_zero_limit():
191 + llm = _make_async_llm(max_num_queued_reqs=0, num_unfinished=0)
192 + with pytest.raises(QueueOverflowError):
193 + llm.check_admission()
194 +
195 +
196 +# -- max_num_queued_tokens --------------------------------------------------
197 +
198 +
199 +def test_admission_tokens_allows_when_under_limit():
200 + llm = _make_async_llm(max_num_queued_tokens=1000, num_queued_tokens=500)
201 + llm.check_admission()
202 +
203 +
204 +def test_admission_tokens_rejects_at_limit():
205 + llm = _make_async_llm(max_num_queued_tokens=1000, num_queued_tokens=1000)
206 + with pytest.raises(MaxQueuedTokensError):
207 + llm.check_admission()
208 +
209 +
210 +def test_admission_tokens_rejects_over_limit():
211 + llm = _make_async_llm(max_num_queued_tokens=1000, num_queued_tokens=1500)
212 + with pytest.raises(MaxQueuedTokensError):
213 + llm.check_admission()
214 +
215 +
216 +def test_admission_tokens_rejects_when_zero_limit():
217 + llm = _make_async_llm(max_num_queued_tokens=0, num_queued_tokens=0)
218 + with pytest.raises(MaxQueuedTokensError):
219 + llm.check_admission()
220 +
221 +
222 +# -- interaction between both limits ----------------------------------------
223 +
224 +
225 +def test_admission_both_limits_checked_independently():
226 + llm = _make_async_llm(
227 + max_num_queued_reqs=100,
228 + max_num_queued_tokens=1000,
229 + num_unfinished=5,
230 + num_queued_tokens=1000,
231 + )
232 + with pytest.raises(MaxQueuedTokensError):
233 + llm.check_admission()
234 +
235 +
236 +def test_admission_req_limit_checked_before_token_limit():
237 + llm = _make_async_llm(
238 + max_num_queued_reqs=10,
239 + max_num_queued_tokens=1000,
240 + num_unfinished=10,
241 + num_queued_tokens=1000,
242 + )
243 + with pytest.raises(QueueOverflowError):
244 + llm.check_admission()
245 +
246 +
247 +# -- n derived from params at the add_request call site ---------------------
248 +
249 +
250 +@pytest.mark.parametrize("params", [SamplingParams(), PoolingParams()])
251 +def test_admission_params_without_explicit_n_count_as_one_slot(params):
252 + """PoolingParams has no ``.n``; add_request must fall back to 1."""
253 + llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=9)
254 + llm.check_admission(getattr(params, "n", 1) or 1)
255 +
256 + llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=10)
257 + with pytest.raises(QueueOverflowError):
258 + llm.check_admission(getattr(params, "n", 1) or 1)
259 +
260 +
261 +@pytest.mark.asyncio
262 +async def test_concurrent_single_request_admission_respects_limit():
263 + """Concurrent n=1 admissions cannot consume the same slot."""
264 + llm = _make_async_llm(max_num_queued_reqs=1)
265 + request_states: dict[str, SimpleNamespace] = {}
266 + llm.output_processor.get_num_unfinished_requests.side_effect = lambda: len(
267 + request_states
268 + )
269 + llm.output_processor.has_request.side_effect = request_states.__contains__
270 +
271 + def add_request(request, *_args):
272 + request_states[request.request_id] = _make_req_state(
273 + len(request.prompt_token_ids)
274 + )
275 +
276 + llm.output_processor.add_request.side_effect = add_request
277 +
278 + async def add_request_async(_request):
279 + await asyncio.sleep(0)
280 +
281 + llm.engine_core = SimpleNamespace(
282 + add_request_async=AsyncMock(side_effect=add_request_async),
283 + shutdown=MagicMock(),
284 + )
285 + llm.log_requests = False
286 +
287 + requests = [
288 + SimpleNamespace(request_id=f"request-{idx}", prompt_token_ids=[idx])
289 + for idx in range(2)
290 + ]
291 + results = await asyncio.gather(
292 + *(
293 + llm._add_request(request, None, None, 0, MagicMock())
294 + for request in requests
295 + ),
296 + return_exceptions=True,
297 + )
298 +
299 + assert sum(result is None for result in results) == 1
300 + assert sum(isinstance(result, QueueOverflowError) for result in results) == 1
301 + assert len(request_states) == 1
302 +
303 +
304 +# ---------------------------------------------------------------------------
305 +# create_error_response integration
306 +# ---------------------------------------------------------------------------
307 +
308 +
309 +def test_queue_overflow_maps_to_503():
310 + resp = create_error_response(QueueOverflowError())
311 + assert resp.error.code == HTTPStatus.SERVICE_UNAVAILABLE.value
312 + assert resp.error.type == HTTPStatus.SERVICE_UNAVAILABLE.phrase
313 + assert resp.error.param is None
314 + msg = resp.error.message.lower()
315 + assert "busy" in msg or "try again" in msg
316 +
317 +
318 +def test_max_queued_tokens_maps_to_503():
319 + resp = create_error_response(MaxQueuedTokensError())
320 + assert resp.error.code == HTTPStatus.SERVICE_UNAVAILABLE.value
321 + assert resp.error.type == HTTPStatus.SERVICE_UNAVAILABLE.phrase
322 + msg = resp.error.message.lower()
323 + assert "backlog" in msg or "try again" in msg
324 +
325 +
326 +def test_custom_graceful_error_maps_to_its_status():
327 + err = GracefulHTTPError("custom", HTTPStatus.SERVICE_UNAVAILABLE)
328 + resp = create_error_response(err)
329 + assert resp.error.code == HTTPStatus.SERVICE_UNAVAILABLE.value
330 + assert resp.error.type == HTTPStatus.SERVICE_UNAVAILABLE.phrase
331 +
332 +
333 +# ---------------------------------------------------------------------------
334 +# SchedulerConfig field defaults
335 +# ---------------------------------------------------------------------------
336 +
337 +
338 +def test_scheduler_config_defaults_are_none():
339 + config = _make_scheduler_config()
340 + assert config.max_num_queued_reqs is None
341 + assert config.max_num_queued_tokens is None
342 +
343 +
344 +def test_scheduler_config_accepts_explicit_values():
345 + config = _make_scheduler_config(
346 + max_num_queued_reqs=100,
347 + max_num_queued_tokens=32000,
348 + )
349 + assert config.max_num_queued_reqs == 100
350 + assert config.max_num_queued_tokens == 32000
351 +
352 +
353 +@pytest.mark.parametrize("field", ["max_num_queued_reqs", "max_num_queued_tokens"])
354 +def test_scheduler_config_rejects_negative(field):
355 + with pytest.raises(ValidationError):
356 + _make_scheduler_config(**{field: -1})
357 +
358 +
359 +# ---------------------------------------------------------------------------
360 +# human_readable_int for CLI notation
361 +# ---------------------------------------------------------------------------
362 +
363 +
364 +@pytest.mark.parametrize(
365 + "input_str, expected",
366 + [
367 + ("32k", 32_000),
368 + ("1k", 1_000),
369 + ("1K", 1_024),
370 + ("1m", 1_000_000),
371 + ("1M", 1_048_576),
372 + ("100", 100),
373 + ("2.5k", 2_500),
374 + ("0", 0),
375 + ],
376 +)
377 +def test_human_readable_int_parses_notation(input_str: str, expected: int):
378 + assert human_readable_int(input_str) == expected
379 +
380 +
381 +@pytest.mark.parametrize("invalid", ["abc", "1x", "", "k", "1.5K"])
382 +def test_human_readable_int_rejects_invalid(invalid: str):
383 + with pytest.raises((argparse.ArgumentTypeError, ValueError)):
384 + human_readable_int(invalid)
vllm/config/scheduler.py
+31
@@ -71,6 +71,37 @@ class SchedulerConfig:
71 """For chunked prefill, a request is considered long if the prompt is
72 longer than this number of tokens. 0 disables the cap (default)."""
73
74 + max_num_queued_reqs: int | None = Field(default=None, ge=0)
75 + """Maximum number of requests that can be in-flight (waiting or running)
76 + at the same time, or None for no limit. When the limit is reached, new
77 + requests are rejected with HTTP 503 so the client can retry on another
78 + instance. This bounds vLLM's otherwise unbounded request queue and is
79 + primarily a coarse capacity valve."""
80 +
81 + max_num_queued_tokens: int | None = Field(default=None, ge=0)
82 + """Maximum total prompt tokens of requests currently in the prefill
83 + phase, or None for no limit. When the limit is reached, new requests
84 + are rejected with HTTP 503.
85 +
86 + This is a TTFT QoS mechanism: by setting it to
87 + ``target_TTFT * prefill_throughput`` you reject requests when the
88 + prefill backlog would exceed the latency target. In a disaggregated
89 + prefill-decode setup this maps directly to the prefill pool's
90 + capacity.
91 +
92 + Note: the count is conservative. A partially prefilled request
93 + still contributes its full ``prompt_len`` until it transitions out
94 + of the prefill phase, because the scheduler's per-iteration
95 + ``num_computed_tokens`` progress is not propagated to the API
96 + server process during prefill (``EngineCoreOutput`` is only
97 + emitted once the request starts producing tokens). Similarly,
98 + prefix-cache hits (``num_cached_tokens``) are only known to the
99 + OutputProcessor after prefill completes. This overestimates the
100 + real backlog, causing earlier rejection than strictly necessary
101 + — the safe direction for QoS. The impact is limited to long
102 + prompts under chunked prefill; short prompts that prefill in a
103 + single iteration are unaffected."""
104 +
105 enable_chunked_prefill: bool = True
106 """If True, prefill requests can be chunked based
107 on the remaining `max_num_batched_tokens`.
vllm/engine/arg_utils.py
+12
@@ -367,6 +367,7 @@ def _compute_kwargs(cls: ConfigType) -> dict[str, dict[str, Any]]:
367 "max_num_scheduled_tokens",
368 "kv_cache_memory_bytes",
369 "safetensors_prefetch_block_size",
370 + "max_num_queued_tokens",
371 }
372 if name == "max_model_len":
373 kwargs[name]["type"] = human_readable_int_or_auto
@@ -544,6 +545,8 @@ class EngineArgs:
545 max_num_scheduled_tokens: int | None = None
546 long_prefill_token_threshold: int = SchedulerConfig.long_prefill_token_threshold
547 max_num_seqs: int | None = None
548 + max_num_queued_reqs: int | None = None
549 + max_num_queued_tokens: int | None = None
550 max_logprobs: int = ModelConfig.max_logprobs
551 logprobs_mode: LogprobsMode = ModelConfig.logprobs_mode
552 use_fp64_gumbel: bool = ModelConfig.use_fp64_gumbel
@@ -1554,6 +1557,13 @@ class EngineArgs:
1557 "default": None,
1558 },
1559 )
1560 + scheduler_group.add_argument(
1561 + "--max-num-queued-reqs", **scheduler_kwargs["max_num_queued_reqs"]
1562 + )
1563 + scheduler_group.add_argument(
1564 + "--max-num-queued-tokens",
1565 + **scheduler_kwargs["max_num_queued_tokens"],
1566 + )
1567 scheduler_group.add_argument(
1568 "--long-prefill-token-threshold",
1569 **scheduler_kwargs["long_prefill_token_threshold"],
@@ -2348,6 +2358,8 @@ class EngineArgs:
2358 max_num_batched_tokens=self.max_num_batched_tokens,
2359 max_num_scheduled_tokens=self.max_num_scheduled_tokens,
2360 max_num_seqs=self.max_num_seqs,
2361 + max_num_queued_reqs=self.max_num_queued_reqs,
2362 + max_num_queued_tokens=self.max_num_queued_tokens,
2363 max_model_len=model_config.max_model_len,
2364 enable_chunked_prefill=self.enable_chunked_prefill,
2365 disable_chunked_mm_input=self.disable_chunked_mm_input,
vllm/engine/protocol.py
+17
@@ -62,6 +62,23 @@ class EngineClient(ABC):
62 @abstractmethod
63 def dead_error(self) -> BaseException: ...
64
65 + def check_admission( # noqa: B027
66 + self, n: int = 1, request_id: str | None = None
67 + ) -> None:
68 + """Reject the request up front if it would exceed queue limits.
69 +
70 + Called before a response is started so that overload rejections can
71 + carry an HTTP status, which is not possible once a streaming response
72 + has begun. Engines without admission control accept everything.
73 +
74 + Args:
75 + n: Number of sequences the request will occupy.
76 + request_id: Request id, used for logging only.
77 +
78 + Raises:
79 + GracefulHTTPError: If the request cannot be admitted.
80 + """
81 +
82 @abstractmethod
83 def generate(
84 self,
vllm/entrypoints/generate/base/serving.py
+13
@@ -171,6 +171,19 @@ class GenerateBaseServing(BaseServing, BeamSearchOnlineMixin):
171 # Never fail server startup over the fingerprint.
172 self.system_fingerprint = None
173
174 + def _preflight(self, n: int = 1) -> None:
175 + """Engine checks that must run before a response is started.
176 +
177 + This is required for the streaming case, where we return a success
178 + status before we actually start generating text :).
179 +
180 + Args:
181 + n: Number of sequences the request will occupy.
182 + """
183 + if self.engine_client.errored:
184 + raise self.engine_client.dead_error
185 + self.engine_client.check_admission(n)
186 +
187 def create_streaming_error_response(
188 self,
189 message: str | Exception,
vllm/entrypoints/generate/generative_scoring/serving.py
+1
@@ -195,6 +195,7 @@ class ServingGenerativeScoring(BaseServing):
195 # Check if engine is alive
196 if self.engine_client.errored:
197 raise self.engine_client.dead_error
198 + self.engine_client.check_admission(len(request.items))
199
200 # Get tokenizer
201 tokenizer = self.renderer.tokenizer
vllm/entrypoints/openai/chat_completion/batch_serving.py
+1 -2
@@ -56,8 +56,7 @@ class OpenAIServingChatBatch(OpenAIServingChat):
56 logger.error("Error with model %s", error_check_ret)
57 return error_check_ret
58
59 - if self.engine_client.errored:
60 - raise self.engine_client.dead_error
59 + self._preflight()
60
61 renderer = self.online_renderer
62
vllm/entrypoints/openai/chat_completion/serving.py
+1 -5
@@ -235,11 +235,7 @@ class OpenAIServingChat(GenerateBaseServing):
235 logger.error("Error with model %s", error_check_ret)
236 return error_check_ret
237
238 - # If the engine is dead, raise the engine's DEAD_ERROR.
239 - # This is required for the streaming case, where we return a
240 - # success status before we actually start generating text :).
241 - if self.engine_client.errored:
242 - raise self.engine_client.dead_error
238 + self._preflight(request.n or 1)
239
240 return await self.online_renderer.render_chat(request)
241
vllm/entrypoints/openai/completion/serving.py
+1 -5
@@ -102,11 +102,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
102 if error_check_ret is not None:
103 return error_check_ret
104
105 - # If the engine is dead, raise the engine's DEAD_ERROR.
106 - # This is required for the streaming case, where we return a
107 - # success status before we actually start generating text :).
108 - if self.engine_client.errored:
109 - raise self.engine_client.dead_error
105 + self._preflight(request.n or 1)
106
107 return await self.online_renderer.render_completion(request)
108
vllm/entrypoints/openai/responses/serving.py
+1 -5
@@ -350,11 +350,7 @@ class OpenAIServingResponses(GenerateBaseServing):
350 if maybe_validation_error is not None:
351 return maybe_validation_error
352
353 - # If the engine is dead, raise the engine's DEAD_ERROR.
354 - # This is required for the streaming case, where we return a
355 - # success status before we actually start generating text :).
356 - if self.engine_client.errored:
357 - raise self.engine_client.dead_error
353 + self._preflight()
354
355 if request.store and not self.enable_store:
356 # Disable the store option.
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+1 -5
@@ -110,11 +110,7 @@ class ServingTokens(GenerateBaseServing):
110 logger.error("Error with model %s", error_check_ret)
111 return error_check_ret
112
113 - # If the engine is dead, raise the engine's DEAD_ERROR.
114 - # This is required for the streaming case, where we return a
115 - # success status before we actually start generating text :).
116 - if self.engine_client.errored:
117 - raise self.engine_client.dead_error
113 + self._preflight()
114
115 lora_request = None
116 lora_request = self._maybe_get_adapters(request, supports_default_mm_loras=True)
vllm/entrypoints/serve/exception_handling/error_response.py
+6 -1
@@ -26,6 +26,7 @@ def create_error_response(
26 )
27
28 from vllm.exceptions import (
29 + GracefulHTTPError,
30 VLLMClientError,
31 VLLMNotFoundError,
32 VLLMServerError,
@@ -33,7 +34,11 @@ def create_error_response(
34 VLLMValidationError,
35 )
36
36 - if isinstance(exc, VLLMValidationError):
37 + if isinstance(exc, GracefulHTTPError):
38 + err_type = HTTPStatus(exc.http_status).phrase
39 + status_code = exc.http_status
40 + param = None
41 + elif isinstance(exc, VLLMValidationError):
42 err_type = "BadRequestError"
43 status_code = HTTPStatus.BAD_REQUEST
44 param = exc.parameter
vllm/entrypoints/speech_to_text/base/serving.py
+1 -5
@@ -440,11 +440,7 @@ class SpeechToTextBaseServing(GenerateBaseServing):
440 if not request.model:
441 request.model = self.models.model_name()
442
443 - # If the engine is dead, raise the engine's DEAD_ERROR.
444 - # This is required for the streaming case, where we return a
445 - # success status before we actually start generating text :).
446 - if self.engine_client.errored:
447 - raise self.engine_client.dead_error
443 + self._preflight()
444
445 if request.response_format not in [
446 "text",
vllm/exceptions.py
+45
@@ -125,3 +125,48 @@ class GenerationError(VLLMServerError):
125 def __init__(self, message: str = "Internal server error"):
126 super().__init__(message)
127 self.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
128 +
129 +
130 +class GracefulHTTPError(VLLMError):
131 + """Exception that should be translated into an HTTP error response.
132 +
133 + These are expected to occur during normal operation (e.g. admission
134 + control rejections) and should be surfaced to the client with the
135 + explicit HTTP status code they carry, rather than being mapped to a
136 + generic 4xx/5xx by the client/server split.
137 + """
138 +
139 + def __init__(self, message: str, http_status: HTTPStatus):
140 + super().__init__(message)
141 + self.message = message
142 + self.http_status = http_status
143 +
144 +
145 +class QueueOverflowError(GracefulHTTPError):
146 + """Raised when admitting a request would exceed the request queue limit.
147 +
148 + Returns HTTP 503 (Service Unavailable) so that load balancers and
149 + client SDKs retry the request on a different instance.
150 + """
151 +
152 + def __init__(self):
153 + super().__init__(
154 + "The engine is currently busy and cannot accept new requests. "
155 + "Please try again later or on a different instance.",
156 + HTTPStatus.SERVICE_UNAVAILABLE,
157 + )
158 +
159 +
160 +class MaxQueuedTokensError(GracefulHTTPError):
161 + """Raised when the pending prefill tokens exceed the configured limit.
162 +
163 + Returns HTTP 503 (Service Unavailable) so that load balancers and
164 + client SDKs retry the request on a different instance.
165 + """
166 +
167 + def __init__(self):
168 + super().__init__(
169 + "The engine has reached its prefill token backlog limit. "
170 + "Please try again later or on a different instance.",
171 + HTTPStatus.SERVICE_UNAVAILABLE,
172 + )
vllm/v1/engine/async_llm.py
+80 -6
@@ -21,7 +21,13 @@ from vllm.distributed.weight_transfer.base import (
21 from vllm.engine.arg_utils import AsyncEngineArgs
22 from vllm.engine.protocol import EngineClient, StreamingInput
23 from vllm.entrypoints.serve.elastic_ep.middleware import set_scaling_elastic_ep
24 -from vllm.exceptions import VLLMClientError, VLLMValidationError
24 +from vllm.exceptions import (
25 + GracefulHTTPError,
26 + MaxQueuedTokensError,
27 + QueueOverflowError,
28 + VLLMClientError,
29 + VLLMValidationError,
30 +)
31 from vllm.inputs import EngineInput, PromptType
32 from vllm.logger import init_logger
33 from vllm.lora.request import LoRARequest
@@ -112,6 +118,7 @@ class AsyncLLM(EngineClient):
118 self.vllm_config = vllm_config
119 self._elastic_ep_lock = asyncio.Lock()
120 self.model_config = vllm_config.model_config
121 + self.scheduler_config = vllm_config.scheduler_config
122 self.observability_config = vllm_config.observability_config
123
124 tracing_endpoint = self.observability_config.otlp_traces_endpoint
@@ -273,6 +280,64 @@ class AsyncLLM(EngineClient):
280 if handler is not None:
281 cancel_task_threadsafe(handler)
282
283 + def get_num_unfinished_requests(self) -> int:
284 + return self.output_processor.get_num_unfinished_requests()
285 +
286 + def get_num_queued_tokens(self) -> int:
287 + return self.output_processor.get_num_queued_tokens()
288 +
289 + def check_admission(self, n: int = 1, request_id: str | None = None) -> None:
290 + """Reject the request if it would exceed queue limits.
291 +
292 + Both limits return HTTP 503 (Service Unavailable) so that load
293 + balancers and client SDKs retry on a different instance.
294 +
295 + - ``max_num_queued_reqs``: hard cap on the number of unfinished requests
296 + (waiting + running). A request with ``n > 1`` counts as ``n`` slots.
297 + - ``max_num_queued_tokens``: TTFT QoS — cap on the total prompt
298 + tokens of requests still in prefill.
299 +
300 + Note: ``get_num_queued_tokens`` uses ``prompt_len`` for all prefilling requests.
301 + Chunked prefill progress and prefix-cache hits are not subtracted because the
302 + scheduler's ``num_computed_tokens`` and ``num_cached_tokens`` are only
303 + propagated to the API server after prefill completes. The overestimation is
304 + conservative — earlier rejection, preserving TTFT targets.
305 +
306 + Args:
307 + n: Number of sequences the request will occupy.
308 + request_id: Request id, used for logging only.
309 +
310 + Raises:
311 + QueueOverflowError: If ``max_num_queued_reqs`` would be exceeded.
312 + MaxQueuedTokensError: If ``max_num_queued_tokens`` would be exceeded.
313 + """
314 + max_num_reqs = self.scheduler_config.max_num_queued_reqs
315 + if max_num_reqs is not None:
316 + current = self.get_num_unfinished_requests()
317 + if current + n > max_num_reqs:
318 + logger.info(
319 + "Request queue full - rejecting request %s "
320 + "(current=%d, n=%d, max=%d).",
321 + request_id,
322 + current,
323 + n,
324 + max_num_reqs,
325 + )
326 + raise QueueOverflowError()
327 +
328 + max_queued_tokens = self.scheduler_config.max_num_queued_tokens
329 + if max_queued_tokens is not None:
330 + current_tokens = self.get_num_queued_tokens()
331 + if current_tokens >= max_queued_tokens:
332 + logger.info(
333 + "Max queued tokens reached - rejecting request %s "
334 + "(current_tokens=%d, max=%d).",
335 + request_id,
336 + current_tokens,
337 + max_queued_tokens,
338 + )
339 + raise MaxQueuedTokensError()
340 +
341 async def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
342 if not hasattr(self, "_supported_tasks"):
343 # Cache the result
@@ -317,6 +382,10 @@ class AsyncLLM(EngineClient):
382 "prompt logprobs"
383 )
384
385 + if isinstance(params, SamplingParams) and params.n > 1:
386 + # TODO (NickLucche) Batch check admission check for all n requests
387 + self.check_admission(params.n, request_id)
388 +
389 if isinstance(prompt, AsyncGenerator):
390 if reasoning_ended is not None or reasoning_parser_kwargs is not None:
391 raise NotImplementedError
@@ -430,7 +499,12 @@ class AsyncLLM(EngineClient):
499 index: int,
500 queue: RequestOutputCollector,
501 ):
433 - # Add the request to OutputProcessor (this process).
502 + if parent_req is None and not self.output_processor.has_request(
503 + request.request_id
504 + ):
505 + self.check_admission(request_id=request.request_id)
506 +
507 + # Register locally before the first await so concurrent tasks see this request.
508 self.output_processor.add_request(request, prompt, parent_req, index, queue)
509
510 # Add the EngineCoreRequest to EngineCore (separate process).
@@ -630,8 +704,8 @@ class AsyncLLM(EngineClient):
704 logger.info("Request %s failed (engine dead).", request_id)
705 raise
706
633 - # Request validation error.
634 - except VLLMClientError as e:
707 + # Request validation error or admission control rejection.
708 + except (VLLMClientError, GracefulHTTPError) as e:
709 if self.log_requests:
710 logger.info("Request %s failed (bad request): %s.", request_id, e)
711 raise
@@ -907,8 +981,8 @@ class AsyncLLM(EngineClient):
981 logger.info("Request %s failed (engine dead).", request_id)
982 raise
983
910 - # Request validation error.
911 - except VLLMClientError:
984 + # Request validation error or admission control rejection.
985 + except (VLLMClientError, GracefulHTTPError):
986 if self.log_requests:
987 logger.info("Request %s failed (bad request).", request_id)
988 raise
vllm/v1/engine/output_processor.py
+14
@@ -467,6 +467,20 @@ class OutputProcessor:
467 def get_num_unfinished_requests(self):
468 return len(self.request_states)
469
470 + def has_request(self, request_id: str) -> bool:
471 + return request_id in self.request_states
472 +
473 + def get_num_queued_tokens(self) -> int:
474 + """Total prompt tokens of requests currently in the prefill phase.
475 +
476 + Uses ``prompt_len`` rather than remaining prefill work because the
477 + scheduler's ``num_computed_tokens`` is not propagated to the API
478 + server until prefill completes. See ``SchedulerConfig`` docs.
479 + """
480 + return sum(
481 + req.prompt_len for req in self.request_states.values() if req.is_prefilling
482 + )
483 +
484 def has_unfinished_requests(self) -> bool:
485 return len(self.request_states) > 0
486