From 3de69dcc26c4e826db3f664dcf45eded5eb3c70e Mon Sep 17 00:00:00 2001 From: Owen Cramer Date: Tue, 8 Sep 2026 14:53:20 -0400 Subject: [PATCH] feat: Add more graceful error handling --- README.md | 19 ++++++ docs/conventions.md | 24 ++++++- src/handler.py | 17 ++++- src/main.py | 73 ++++++++++++++++++-- src/startup_errors.py | 118 ++++++++++++++++++++++++++++++++ tests/test_handler.py | 47 +++++++++++++ tests/test_startup_errors.py | 128 +++++++++++++++++++++++++++++++++++ 7 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 src/startup_errors.py create mode 100644 tests/test_startup_errors.py diff --git a/README.md b/README.md index b47e56cf..75ca1a06 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Current vLLM version: [0.29.0](https://github.com/vllm-project/vllm/releases/tag - [Setting up the Serverless Worker](#setting-up-the-serverless-worker) - [Option 1: Deploy Any Model Using Pre-Built Docker Image [Recommended]](#option-1-deploy-any-model-using-pre-built-docker-image-recommended) - [Configuration](#configuration) + - [When vLLM will not start](#when-vllm-will-not-start) - [Option 2: Build Docker Image with Model Inside](#option-2-build-docker-image-with-model-inside) - [Prerequisites](#prerequisites) - [Arguments](#arguments) @@ -102,6 +103,24 @@ Mount the file anywhere into the container and point the `VLLM_CONFIG_FILE` env For the complete list of all available environment variables, examples, and detailed descriptions: **[Configuration](docs/configuration.md)** +### When vLLM will not start + +Some startup failures a restart cannot fix: the model does not fit the GPU, `MAX_MODEL_LEN` is larger than the KV cache the GPU can hold, a flag or value vLLM rejects, a gated model without `HF_TOKEN`. Exiting on those crash-loops the worker: every attempt pays for the download and the model load, and the console shows only silent restarts with no error anywhere. + +The worker recognises these and stays up instead, answering every job with the cause and the fix: + +```json +{ + "error": { + "message": "Qwen/Qwen3-30B-A3B ran out of GPU memory during startup. This GPU has 19.57 GiB. Lower MAX_MODEL_LEN or MAX_NUM_SEQS, set ENFORCE_EAGER=true to skip CUDA graph capture, use a quantized checkpoint, or redeploy on a larger GPU (or more GPUs with TENSOR_PARALLEL_SIZE). ...", + "type": "startup_error", + "code": null + } +} +``` + +The same message is logged by the worker, and the full vLLM traceback stays in the worker logs above it. Anything unrecognised still exits non-zero, because a failed download or a bad host is worth another attempt. + ## Option 2: Build Docker Image with Model Inside To build an image with the model baked in, you must specify the following docker arguments when building the image. diff --git a/docs/conventions.md b/docs/conventions.md index 227df8a6..290f85ee 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -27,7 +27,9 @@ Container start: ├─ (optional) reads /local_model_args.json for baked-in models ├─ args_builder.build_vllm_args() → env vars → CLI flags ├─ spawns `vllm serve --host 127.0.0.1 --port $VLLM_PORT ...` - ├─ polls GET /health until ready (fail fast if vLLM exits or times out) + ├─ polls GET /health until ready (exit non-zero if vLLM dies or times out, + │ unless startup_errors.classify() recognises the failure as one a restart + │ cannot fix — then stay up and answer every job with the cause) └─ starts runpod.serverless loop with handler.handler Per job: @@ -45,6 +47,9 @@ boundary or the CLI; that is what makes vLLM upgrades a one-line `VLLM_VERSION` - `src/args_builder.py`: pure-Python env var → CLI flag translation (allowlist of `vllm serve` flags + legacy aliases + `VLLM_EXTRA_ARGS` passthrough). No third-party imports, so it is fully unit-testable on CPU runners. +- `src/startup_errors.py`: regexes over the tail of vLLM's output that turn a known + fatal startup failure (CUDA OOM, KV cache too small for `MAX_MODEL_LEN`, rejected + flag, gated/missing model, out of disk) into one actionable sentence. Pure Python. - `src/handler.py`: the RunPod serverless handler; an aiohttp proxy that accepts three input shapes: 1. `{"openai_route": ..., "openai_input": ...}` — RunPod's `/openai/*` passthrough @@ -93,6 +98,7 @@ VLLM_EXTRA_ARGS > env aliases (MODEL_NAME, ...) > env flag scan src/ ├── main.py # Entrypoint: vLLM subprocess + RunPod loop lifecycle ├── args_builder.py # env vars → `vllm serve` CLI flags (pure Python) +├── startup_errors.py # fatal startup failure → actionable message (pure Python) ├── handler.py # RunPod handler: aiohttp proxy to the vLLM server └── download_model.py # Build-time model download (Option 2) ``` @@ -132,8 +138,17 @@ workers at container start. ### 3. **Error Handling** -- Startup failures (bad flag, missing model, OOM during load) → vLLM exits before - `/health` → `main.py` fails the worker fast instead of accepting jobs. +- Startup failures → vLLM exits before `/health`. `main.py` keeps the last ~400 lines + of vLLM output and runs `startup_errors.classify()` over them: + - Recognised as unfixable by a restart (CUDA OOM, KV cache too small for + `MAX_MODEL_LEN`, `MAX_MODEL_LEN` above the model's limit, argparse rejection, + gated/missing HF repo, unsupported architecture, out of disk) → the worker stays + up and every job returns `{"error": {"type": "startup_error", "message": ...}}` + naming the cause and the fix. Crash-looping would pay for the download and load + on every attempt and surface nothing but silent restarts in the console. + - Anything else (flaky download, bad host) → exit non-zero so the platform retries. + Add a pattern only when a restart provably cannot help; a false positive here turns + a transient failure into a worker that never recovers. - Request failures → the proxy yields a RunPod job error containing the vLLM HTTP status and body (vLLM's own OpenAI-compatible error payloads flow through unchanged). - If the vLLM process dies while serving, the next job gets an immediate @@ -178,6 +193,9 @@ workers at container start. - `tests/test_args_builder.py` covers the env var → CLI translation (mapping, aliases, bool handling, JSON passthrough, `VLLM_EXTRA_ARGS` precedence). Pure Python, no GPU, no vllm import — runs on any CPU runner with just `pytest`. +- `tests/test_startup_errors.py` pins which vLLM failure messages are answered and + which are left for a restart; `tests/test_handler.py` checks the `startup_error` + short-circuit in the handler. ### 2. **Local Smoke Testing** diff --git a/src/handler.py b/src/handler.py index 8980dbf9..c3596542 100644 --- a/src/handler.py +++ b/src/handler.py @@ -2,7 +2,9 @@ `main.py` starts `vllm serve` on 127.0.0.1:VLLM_PORT and only then starts the RunPod serverless loop, so by the time a job arrives the vLLM HTTP server is up -and fully backwards/forwards compatible — we never import vLLM. +and fully backwards/forwards compatible — we never import vLLM. The one +exception: when vLLM could not start for a reason a restart cannot fix, +`main.py` sets `startup_error` and every job is answered with that message. Accepted job input shapes (all under job["input"]): @@ -38,6 +40,11 @@ # instead of hanging on a dead server. vllm_process = None +# Set by main.py when vLLM failed to start for a reason a restart cannot fix +# (see startup_errors.py). Every job then answers with the cause instead of +# dialling a server that is not there. +startup_error: Optional[str] = None + _default_model_cache: Optional[str] = None @@ -103,11 +110,15 @@ def _normalize_job_input(job_input: dict) -> Tuple[str, str, Optional[dict]]: return DEFAULT_COMPLETION_ROUTE, "POST", body -def _error(message: str) -> dict: - return {"error": {"message": message, "type": "worker_error", "code": None}} +def _error(message: str, error_type: str = "worker_error") -> dict: + return {"error": {"message": message, "type": error_type, "code": None}} async def handler(job: dict) -> AsyncGenerator[Any, None]: + if startup_error: + yield _error(startup_error, error_type="startup_error") + return + job_input = job.get("input") or {} try: diff --git a/src/main.py b/src/main.py index 80205660..b04a100e 100644 --- a/src/main.py +++ b/src/main.py @@ -4,18 +4,27 @@ (see args_builder.py), launch `vllm serve` on the loopback interface, poll /health until the server (and model) is ready, and only then start the RunPod serverless job loop so no job is pulled before the backend can serve it. + +If vLLM dies during startup for a reason a restart cannot fix (CUDA OOM, a +MAX_MODEL_LEN the GPU cannot hold, a bad flag, a gated model), the worker stays +up and answers every job with the cause instead of crash-looping; see +startup_errors.py. Unrecognised failures still exit non-zero so the platform +retries them. """ +import collections import json import logging import os import signal import subprocess import sys +import threading import time import urllib.error import urllib.request +import startup_errors from args_builder import build_vllm_args from download_model import LOCAL_MODEL_ARGS_PATH @@ -25,9 +34,16 @@ VLLM_PORT = os.getenv("VLLM_PORT", "8000") STARTUP_TIMEOUT = int(os.getenv("VLLM_STARTUP_TIMEOUT", "1200")) # seconds HEALTH_POLL_INTERVAL = 2 # seconds +# How long to wait for vLLM to exit after SIGTERM before SIGKILL. +SHUTDOWN_GRACE = 30 # seconds vllm_process: subprocess.Popen | None = None +# Enough of vLLM's output to recognise why it died. The traceback that matters +# is always the last thing it prints. +recent_output: collections.deque[str] = collections.deque(maxlen=400) +output_pump: threading.Thread | None = None + def apply_local_model_args() -> None: """Load args baked into the image by download_model.py (Option 2 builds). @@ -52,13 +68,51 @@ def apply_local_model_args() -> None: os.environ["HF_HUB_OFFLINE"] = "1" +def pump_output(proc: subprocess.Popen) -> None: + """Echo vLLM's output to our stdout while keeping the tail for diagnosis.""" + for line in proc.stdout: # type: ignore[union-attr] + sys.stdout.write(line) + sys.stdout.flush() + recent_output.append(line) + + def start_vllm() -> subprocess.Popen: + global output_pump + argv = ["vllm", "serve", "--host", VLLM_HOST, "--port", VLLM_PORT] argv += build_vllm_args() logging.info("Starting vLLM: %s", " ".join(argv)) - # Child gets our stdout/stderr so vLLM logs land in the worker logs. - return subprocess.Popen(argv) + # vLLM's stdout+stderr flow through a pipe so we can both forward them to the + # worker logs and keep the tail to classify a startup failure. Unbuffered so + # the child's log lines arrive as they are written, not when its buffer fills. + env = {**os.environ, "PYTHONUNBUFFERED": "1"} + proc = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + env=env, + ) + output_pump = threading.Thread(target=pump_output, args=(proc,), daemon=True) + output_pump.start() + return proc + + +def stop_vllm(proc: subprocess.Popen) -> None: + """Make sure a failed vLLM is gone (and its GPU memory released).""" + if proc.poll() is None: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=SHUTDOWN_GRACE) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + # Let the pump drain whatever the pipe still holds so the traceback tail + # is in recent_output before we look at it. + if output_pump is not None: + output_pump.join(timeout=10) def wait_for_vllm(proc: subprocess.Popen) -> None: @@ -101,18 +155,29 @@ def main() -> None: signal.signal(sig, _forward_signal) vllm_process = start_vllm() + startup_error = None try: wait_for_vllm(vllm_process) except RuntimeError as e: logging.error("%s", e) - sys.exit(1) + stop_vllm(vllm_process) + startup_error = startup_errors.classify("".join(recent_output), model=os.getenv("MODEL_NAME")) + if startup_error is None: + # Nothing recognisable, so let the platform restart us: a failed + # download or a bad host is worth another attempt. + sys.exit(1) + # A restart cannot fix this one. Exiting would crash-loop the worker, + # paying for the download and the load on every attempt and showing the + # user a traceback instead of a cause, so stay up and answer jobs with it. + logging.error("vLLM cannot start on this configuration; answering jobs with the cause: %s", startup_error) # Import here (not at module import time) so the RunPod SDK and handler - # start only after the backend is confirmed healthy. + # start only after the backend is confirmed healthy (or confirmed dead). import handler as proxy_handler import runpod proxy_handler.vllm_process = vllm_process + proxy_handler.startup_error = startup_error max_concurrency = int(os.getenv("MAX_CONCURRENCY", "30")) runpod.serverless.start( diff --git a/src/startup_errors.py b/src/startup_errors.py new file mode 100644 index 00000000..6b9fd1e2 --- /dev/null +++ b/src/startup_errors.py @@ -0,0 +1,118 @@ +"""Turn a failed `vllm serve` start into a sentence the user can act on. + +vLLM reports its problems as Python tracebacks a hundred lines long, and the +worker only sees them as stdout. When a known one goes by we would rather answer +the next job with the cause and the fix than let the container exit and be +restarted into the same wall: a crash-loop pays for the download and the model +load on every attempt and the console shows nothing but silent restarts. + +Only failures that a restart cannot fix belong here. Anything unrecognised is +left alone (``classify`` returns None) so the platform retries it, which is the +right move for a flaky download or a host that lost its GPU. +""" + +import re +from typing import Optional + +# --- GPU memory ------------------------------------------------------------- +# torch prints both the request and the card's capacity; the capacity is the +# useful half, since the request is whatever happened to be next. +_OOM = re.compile(r"torch\.OutOfMemoryError|CUDA out of memory", re.I) +_OOM_CAPACITY = re.compile(r"total capacity of ([\d.]+) GiB", re.I) +# vllm/v1/core/kv_cache_utils.py: the weights fit but nothing is left for the +# KV cache, or not enough is left to hold a single request of max_model_len. +_NO_KV_MEMORY = re.compile(r"No available memory for the cache blocks", re.I) +_KV_TOO_SMALL = re.compile( + r"larger than the available KV cache memory" + r"|larger than the maximum number of tokens that can be stored in KV cache", + re.I, +) +_KV_ESTIMATED_LEN = re.compile(r"estimated maximum model length is (\d+)", re.I) + +# --- Configuration ---------------------------------------------------------- +# vllm/config/model.py: MAX_MODEL_LEN above what the model's config allows. +_MAX_LEN_EXCEEDS_MODEL = re.compile( + r"User-specified max_model_len \((\d+)\) is greater than the derived max_model_len", re.I +) +# argparse: exit code 2 before the engine even starts. +_BAD_ARGS = re.compile(r"(?:vllm serve|vllm): error: (.+)") + +# --- Model access ----------------------------------------------------------- +_GATED = re.compile(r"GatedRepoError|401 Client Error", re.I) +_NOT_FOUND = re.compile(r"RepositoryNotFoundError|404 Client Error", re.I) +_UNSUPPORTED_ARCH = re.compile(r"Model architectures \[.*?\] (?:are not supported|failed to be inspected)", re.I) + +# --- Disk ------------------------------------------------------------------- +_NO_SPACE = re.compile(r"No space left on device|ENOSPC|errno 28", re.I) + + +def classify(output: str, model: Optional[str] = None) -> Optional[str]: + """One actionable message for a known fatal failure, or None to let it retry.""" + named = model or "The model" + + # Memory problems first: an OOM traceback is often surrounded by secondary + # errors (engine core died, connection reset) that would match nothing. + if _OOM.search(output) or _NO_KV_MEMORY.search(output) or _KV_TOO_SMALL.search(output): + capacity = _OOM_CAPACITY.search(output) + card = f" This GPU has {capacity.group(1)} GiB." if capacity else "" + estimated = _KV_ESTIMATED_LEN.search(output) + hint = ( + f" vLLM estimates this GPU can serve a context of about {estimated.group(1)} tokens." + if estimated + else "" + ) + return ( + f"{named} ran out of GPU memory during startup.{card}{hint} " + f"Lower MAX_MODEL_LEN or MAX_NUM_SEQS, set ENFORCE_EAGER=true to skip " + f"CUDA graph capture, use a quantized checkpoint, or redeploy on a " + f"larger GPU (or more GPUs with TENSOR_PARALLEL_SIZE). If the model " + f"loaded but the KV cache did not fit, raising GPU_MEMORY_UTILIZATION " + f"a little (default 0.9) can also help." + ) + + match = _MAX_LEN_EXCEEDS_MODEL.search(output) + if match: + return ( + f"MAX_MODEL_LEN={match.group(1)} is larger than the context length " + f"{named} declares in its config.json. Lower MAX_MODEL_LEN (or unset it " + f"to use the model's own limit). To override the limit anyway, set " + f"VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 and expect degraded output past it." + ) + + match = _BAD_ARGS.search(output) + if match: + return ( + f"vLLM rejected its command line: {match.group(1).strip()} " + f"Check the environment variables that map to vLLM flags and " + f"VLLM_EXTRA_ARGS; run `vllm serve --help` in the image for the " + f"accepted flags and values." + ) + + if _GATED.search(output): + return ( + f"{named} is gated or private on Hugging Face and the worker was not " + f"allowed to download it. Set HF_TOKEN to a token whose account has " + f"accepted the model's license, or pick a public model." + ) + + if _NOT_FOUND.search(output): + return ( + f"{named} was not found on Hugging Face. Check MODEL_NAME for typos " + f"(it must be the full `org/repo` id) and MODEL_REVISION if set. Private " + f"repositories also return not-found until HF_TOKEN grants access." + ) + + if _UNSUPPORTED_ARCH.search(output): + return ( + f"{named} uses an architecture this vLLM version cannot serve. Check " + f"the vLLM supported-models list, or try a newer worker release." + ) + + if _NO_SPACE.search(output): + return ( + f"{named} ran out of disk while downloading. Increase the endpoint's " + f"container disk to comfortably exceed the size of the repository, or " + f"attach a network volume so the weights are cached there instead." + ) + + return None diff --git a/tests/test_handler.py b/tests/test_handler.py index f2cc0d0c..b79183d9 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -54,3 +54,50 @@ def test_prompt_maps_to_completions(self): def test_empty_input_raises(self): with pytest.raises(ValueError): _normalize_job_input({}) + + +def _collect(job: dict) -> list: + """Drain the async-generator handler synchronously.""" + import asyncio + + import handler as handler_module + + async def run(): + return [item async for item in handler_module.handler(job)] + + return asyncio.run(run()) + + +class TestStartupError: + """main.py sets handler.startup_error when vLLM died for a reason a restart cannot fix.""" + + def test_answers_every_job_with_the_cause(self, monkeypatch): + import handler as handler_module + + monkeypatch.setattr(handler_module, "startup_error", "Ran out of GPU memory.") + + outputs = _collect({"input": {"prompt": "x"}}) + + assert outputs == [{"error": {"message": "Ran out of GPU memory.", "type": "startup_error", "code": None}}] + + def test_wins_over_a_dead_process_and_bad_input(self, monkeypatch): + import handler as handler_module + + monkeypatch.setattr(handler_module, "startup_error", "Ran out of GPU memory.") + # Either of these would otherwise be reported instead, and both say less. + monkeypatch.setattr(handler_module, "_is_vllm_alive", lambda: False) + + outputs = _collect({"input": {}}) + + assert "GPU memory" in outputs[0]["error"]["message"] + + def test_a_healthy_start_leaves_jobs_alone(self, monkeypatch): + import handler as handler_module + + monkeypatch.setattr(handler_module, "startup_error", None) + monkeypatch.setattr(handler_module, "_is_vllm_alive", lambda: False) + + outputs = _collect({"input": {"prompt": "x"}}) + + # Falls through to the normal liveness check rather than short-circuiting. + assert "not running" in outputs[0]["error"]["message"] diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py new file mode 100644 index 00000000..3f31deb5 --- /dev/null +++ b/tests/test_startup_errors.py @@ -0,0 +1,128 @@ +"""Which vLLM startup failures are worth answering, and which are worth a restart.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from startup_errors import classify # noqa: E402 + +TORCH_OOM = """ +(EngineCore_DP0 pid=245) ERROR 08-19 04:02:11 [core.py:1346] EngineCore failed to start. +(EngineCore_DP0 pid=245) ERROR 08-19 04:02:11 [core.py:1346] torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 108.00 MiB. +GPU 0 has a total capacity of 19.57 GiB of which 105.81 MiB is free. +""" +NO_KV_MEMORY = ( + "ValueError: No available memory for the cache blocks. Try increasing " + "`gpu_memory_utilization` when initializing the engine." +) +KV_TOO_SMALL = ( + "ValueError: To serve at least one request with the model's max seq len (131072), " + "(16.00 GiB KV cache is needed, which is larger than the available KV cache memory " + "(4.21 GiB). Based on the available memory, the estimated maximum model length is 34480. " + "Try increasing `gpu_memory_utilization` or decreasing `max_model_len` when initializing the engine." +) +KV_TOO_SMALL_OLD = ( + "ValueError: The model's max seq len (131072) is larger than the maximum number of " + "tokens that can be stored in KV cache (34480)." +) +MAX_LEN_EXCEEDS_MODEL = ( + "ValueError: User-specified max_model_len (65536) is greater than the derived " + "max_model_len (max_position_embeddings=32768 or model_max_length=None in model's config.json)." +) +BAD_ARGS = "vllm serve: error: unrecognized arguments: --foo-bar 3" +BAD_VALUE = "vllm serve: error: argument --max-model-len: invalid int value: 'lots'" +GATED = ( + "huggingface_hub.errors.GatedRepoError: 401 Client Error. Cannot access gated repo " + "for url https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct/resolve/main/config.json." +) +NOT_FOUND = ( + "huggingface_hub.errors.RepositoryNotFoundError: 404 Client Error. Repository Not Found " + "for url https://huggingface.co/org/nope/resolve/main/config.json." +) +UNSUPPORTED = "ValueError: Model architectures ['FooForCausalLM'] are not supported for now." +NO_SPACE = "OSError: [Errno 28] No space left on device" + + +class TestOutOfMemory: + def test_torch_oom_names_the_model_and_the_card(self): + message = classify(TORCH_OOM, model="Qwen/Qwen3-30B-A3B") + + assert message.startswith("Qwen/Qwen3-30B-A3B ran out of GPU memory") + assert "19.57 GiB" in message + + def test_advice_covers_the_knobs_the_worker_exposes(self): + message = classify(TORCH_OOM) + + for knob in ("MAX_MODEL_LEN", "ENFORCE_EAGER", "GPU_MEMORY_UTILIZATION", "TENSOR_PARALLEL_SIZE"): + assert knob in message + + def test_no_kv_cache_memory_is_out_of_memory(self): + assert "ran out of GPU memory" in classify(NO_KV_MEMORY) + + def test_kv_cache_too_small_passes_on_the_estimated_context(self): + message = classify(KV_TOO_SMALL) + + assert "ran out of GPU memory" in message + assert "34480 tokens" in message + + def test_older_kv_cache_wording_is_recognised(self): + assert classify(KV_TOO_SMALL_OLD) is not None + + def test_oom_wins_over_surrounding_noise(self): + assert "GPU memory" in classify(f"Connection reset by peer\n{TORCH_OOM}\nEngineCore died") + + +class TestConfiguration: + def test_max_model_len_above_the_model_limit_names_the_value(self): + message = classify(MAX_LEN_EXCEEDS_MODEL, model="org/model") + + assert "MAX_MODEL_LEN=65536" in message + assert "VLLM_ALLOW_LONG_MAX_MODEL_LEN" in message + + @pytest.mark.parametrize("output", [BAD_ARGS, BAD_VALUE]) + def test_argparse_rejections_quote_the_reason(self, output): + message = classify(output) + + assert message.startswith("vLLM rejected its command line") + assert output.split("error: ", 1)[1] in message + assert "VLLM_EXTRA_ARGS" in message + + +class TestModelAccess: + def test_gated_repo_points_at_hf_token(self): + assert "HF_TOKEN" in classify(GATED, model="meta-llama/Llama-3.1-8B-Instruct") + + def test_missing_repo_points_at_model_name(self): + assert "MODEL_NAME" in classify(NOT_FOUND) + + def test_unsupported_architecture(self): + assert "architecture" in classify(UNSUPPORTED) + + +def test_out_of_disk_points_at_container_disk(): + assert "container disk" in classify(NO_SPACE) + + +def test_falls_back_to_a_generic_subject(): + assert classify(NO_SPACE).startswith("The model") + + +class TestUnknownFailures: + @pytest.mark.parametrize( + "output", + [ + "", + "Connection reset by peer while downloading", + "RuntimeError: something nobody has seen before", + "vLLM serve exited during startup with code 1", + # Only vLLM's own argparse errors count, not any line with "error:". + "INFO 08-19 [launcher.py] error: none", + ], + ) + def test_are_left_for_the_platform_to_retry(self, output): + # Answering these forever would turn a flaky download into a dead + # endpoint; a restart is the right response. + assert classify(output) is None