[Bugfix] Return HTTP 422 for unprocessable image URLs instead of 500 (#47165)

Signed-off-by: Alexis Kinsella <alexis.kinsella@gmail.com>

Alexis K. committed Jul 6, 2026 at 13:56 UTC 40cc2e83278f9280d80c2527fe03be1c5aa8c91f
5 files changed +288 -12
tests/multimodal/media/test_unprocessable_entity_error.py new
+159
@@ -0,0 +1,159 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""Tests for VLLMUnprocessableEntityError and media fetch error handling.
5 +
6 +Verifies that unprocessable image URLs (404, 403, DNS failures, etc.) return
7 +HTTP 422 instead of 500.
8 +"""
9 +
10 +from http import HTTPStatus
11 +from unittest.mock import AsyncMock, MagicMock, patch
12 +
13 +import aiohttp
14 +import pytest
15 +
16 +from vllm.entrypoints.serve.utils.error_response import create_error_response
17 +from vllm.exceptions import VLLMUnprocessableEntityError
18 +from vllm.multimodal.media import MediaConnector
19 +
20 +
21 +class TestVLLMUnprocessableEntityError:
22 + """Tests for VLLMUnprocessableEntityError exception."""
23 +
24 + def test_creation(self):
25 + exc = VLLMUnprocessableEntityError("Test error")
26 + assert str(exc) == "Test error"
27 + assert exc.parameter is None
28 +
29 + def test_creation_with_parameter_and_value(self):
30 + exc = VLLMUnprocessableEntityError(
31 + "Test error",
32 + parameter="image_url",
33 + value="https://example.com/image.jpg",
34 + )
35 + assert "parameter=image_url" in str(exc)
36 + assert "value=https://example.com/image.jpg" in str(exc)
37 +
38 + def test_is_value_error_subclass(self):
39 + exc = VLLMUnprocessableEntityError("Test")
40 + assert isinstance(exc, ValueError)
41 +
42 +
43 +class TestMediaConnectorErrorHandling:
44 + """Tests for MediaConnector error handling."""
45 +
46 + @pytest.mark.asyncio
47 + async def test_fetch_image_async_404(self):
48 + connector = MediaConnector()
49 +
50 + with patch.object(
51 + connector.connection, "async_get_bytes", new_callable=AsyncMock
52 + ) as mock_get:
53 + mock_get.side_effect = aiohttp.ClientResponseError(
54 + request_info=MagicMock(),
55 + history=(),
56 + status=404,
57 + message="Not Found",
58 + )
59 +
60 + with pytest.raises(VLLMUnprocessableEntityError) as exc_info:
61 + await connector.fetch_image_async("https://example.com/missing.jpg")
62 +
63 + assert exc_info.value.parameter == "image_url"
64 +
65 + @pytest.mark.asyncio
66 + async def test_fetch_image_async_dns_error(self):
67 + """DNS errors are transient and should remain as-is for retry."""
68 + connector = MediaConnector()
69 +
70 + with patch.object(
71 + connector.connection, "async_get_bytes", new_callable=AsyncMock
72 + ) as mock_get:
73 + mock_get.side_effect = aiohttp.ClientConnectorDNSError(
74 + connection_key=MagicMock(),
75 + os_error=MagicMock(),
76 + )
77 +
78 + with pytest.raises(aiohttp.ClientConnectorDNSError) as exc_info:
79 + await connector.fetch_image_async(
80 + "https://nonexistent.example/image.jpg"
81 + )
82 +
83 + assert isinstance(exc_info.value, aiohttp.ClientConnectorDNSError)
84 +
85 + @pytest.mark.asyncio
86 + async def test_fetch_image_async_500_preserved(self):
87 + """5xx errors should remain as server errors."""
88 + connector = MediaConnector()
89 +
90 + with patch.object(
91 + connector.connection, "async_get_bytes", new_callable=AsyncMock
92 + ) as mock_get:
93 + mock_get.side_effect = aiohttp.ClientResponseError(
94 + request_info=MagicMock(),
95 + history=(),
96 + status=500,
97 + message="Internal Server Error",
98 + )
99 +
100 + with pytest.raises(aiohttp.ClientResponseError) as exc_info:
101 + await connector.fetch_image_async("https://example.com/image.jpg")
102 +
103 + assert exc_info.value.status == 500
104 +
105 + def test_fetch_image_404(self):
106 + connector = MediaConnector()
107 +
108 + with patch.object(
109 + connector.connection, "get_bytes", new_callable=MagicMock
110 + ) as mock_get:
111 + mock_get.side_effect = aiohttp.ClientResponseError(
112 + request_info=MagicMock(),
113 + history=(),
114 + status=404,
115 + message="Not Found",
116 + )
117 +
118 + with pytest.raises(VLLMUnprocessableEntityError) as exc_info:
119 + connector.fetch_image("https://example.com/missing.jpg")
120 +
121 + assert exc_info.value.parameter == "image_url"
122 +
123 + def test_fetch_image_connection_error(self):
124 + """Connection errors are transient and should remain as-is for retry."""
125 + connector = MediaConnector()
126 +
127 + with patch.object(
128 + connector.connection, "get_bytes", new_callable=MagicMock
129 + ) as mock_get:
130 + mock_get.side_effect = aiohttp.ClientConnectionError("Connection refused")
131 +
132 + with pytest.raises(aiohttp.ClientConnectionError) as exc_info:
133 + connector.fetch_image("https://example.com/image.jpg")
134 +
135 + assert isinstance(exc_info.value, aiohttp.ClientConnectionError)
136 +
137 +
138 +class TestErrorResponse:
139 + """Tests for error response creation."""
140 +
141 + def test_unprocessable_entity_returns_422(self):
142 + exc = VLLMUnprocessableEntityError(
143 + "Failed to fetch media from URL: Cannot connect",
144 + parameter="image_url",
145 + value="https://example.com/image.jpg",
146 + )
147 +
148 + response = create_error_response(exc)
149 +
150 + assert response.error.code == HTTPStatus.UNPROCESSABLE_ENTITY.value
151 + assert response.error.type == "UnprocessableEntityError"
152 + assert response.error.param == "image_url"
153 +
154 + def test_unprocessable_entity_message(self):
155 + exc = VLLMUnprocessableEntityError("Test error message")
156 + response = create_error_response(exc)
157 +
158 + assert response.error.message == "Test error message"
159 + assert response.error.code == 422
vllm/entrypoints/openai/api_server.py
+2 -1
@@ -51,7 +51,7 @@ from vllm.entrypoints.serve.utils.server_utils import (
51 log_response,
52 validation_exception_handler,
53 )
54 -from vllm.exceptions import VLLMValidationError
54 +from vllm.exceptions import VLLMUnprocessableEntityError, VLLMValidationError
55 from vllm.logger import init_logger
56 from vllm.reasoning import ReasoningParserManager
57 from vllm.renderers.online_derenderer import OnlineDerenderer
@@ -245,6 +245,7 @@ def build_app(
245 app.exception_handler(EngineDeadError)(engine_error_handler)
246 app.exception_handler(GenerationError)(generation_error_handler)
247 app.exception_handler(VLLMValidationError)(exception_handler)
248 + app.exception_handler(VLLMUnprocessableEntityError)(exception_handler)
249 app.exception_handler(Exception)(exception_handler)
250
251 # Ensure --api-key option from CLI takes precedence over VLLM_API_KEY
vllm/entrypoints/serve/utils/error_response.py
+9 -1
@@ -27,12 +27,20 @@ def create_error_response(
27 "create_error_response called with %s: %s", type(exc).__name__, exc
28 )
29
30 - from vllm.exceptions import VLLMNotFoundError, VLLMValidationError
30 + from vllm.exceptions import (
31 + VLLMNotFoundError,
32 + VLLMUnprocessableEntityError,
33 + VLLMValidationError,
34 + )
35
36 if isinstance(exc, VLLMValidationError):
37 err_type = "BadRequestError"
38 status_code = HTTPStatus.BAD_REQUEST
39 param = exc.parameter
40 + elif isinstance(exc, VLLMUnprocessableEntityError):
41 + err_type = "UnprocessableEntityError"
42 + status_code = HTTPStatus.UNPROCESSABLE_ENTITY
43 + param = exc.parameter
44 elif isinstance(exc, VLLMNotFoundError):
45 err_type = "NotFoundError"
46 status_code = HTTPStatus.NOT_FOUND
vllm/exceptions.py
+34
@@ -64,3 +64,37 @@ class LoRAAdapterNotFoundError(VLLMNotFoundError):
64
65 def __str__(self):
66 return self.message
67 +
68 +
69 +class VLLMUnprocessableEntityError(ValueError):
70 + """vLLM-specific error for unprocessable entity requests.
71 +
72 + This exception is raised when the request content is invalid or cannot be
73 + processed, such as when an image URL points to a non-existent or inaccessible
74 + resource (404, 403, DNS failure, etc.).
75 +
76 + Args:
77 + message: The error message describing the unprocessable entity.
78 + parameter: Optional parameter name that failed validation.
79 + value: Optional value that was rejected during validation.
80 + """
81 +
82 + def __init__(
83 + self,
84 + message: str,
85 + *,
86 + parameter: str | None = None,
87 + value: Any = None,
88 + ) -> None:
89 + super().__init__(message)
90 + self.parameter = parameter
91 + self.value = value
92 +
93 + def __str__(self):
94 + base = super().__str__()
95 + extras = []
96 + if self.parameter is not None:
97 + extras.append(f"parameter={self.parameter}")
98 + if self.value is not None:
99 + extras.append(f"value={self.value}")
100 + return f"{base} ({', '.join(extras)})" if extras else base
vllm/multimodal/media/connector.py
+84 -10
@@ -13,14 +13,17 @@ from pathlib import Path
13 from typing import Any, TypeVar
14 from urllib.request import url2pathname
15
16 +import aiohttp
17 import numpy as np
18 import numpy.typing as npt
19 +import requests
20 import torch
21 from PIL import Image, UnidentifiedImageError
22 from urllib3.util import Url, parse_url
23
24 import vllm.envs as envs
25 from vllm.connections import HTTPConnection, global_http_connection
26 +from vllm.exceptions import VLLMUnprocessableEntityError
27 from vllm.logger import init_logger
28 from vllm.multimodal.video import get_video_loader_backend_for_processor
29 from vllm.utils.registry import ExtensionManager
@@ -48,6 +51,65 @@ MODALITY_IO_MAP: dict[str, type[MediaIO]] = {
51 }
52
53
54 +def _wrap_media_fetch_error(
55 + url: str, exc: Exception
56 +) -> VLLMUnprocessableEntityError | Exception:
57 + """Convert media fetch exceptions to VLLMUnprocessableEntityError.
58 +
59 + This handles HTTP errors that indicate the media resource is invalid
60 + (4xx responses except 408/429, malformed URLs) and converts them to a
61 + 422 Unprocessable Entity error instead of 500.
62 +
63 + Transient errors (5xx, 408, 429, DNS failures, connection errors,
64 + timeouts) are returned as-is to allow retry logic to handle them
65 + appropriately.
66 +
67 + Returns:
68 + VLLMUnprocessableEntityError for permanent client errors (4xx except
69 + 408/429, invalid URL)
70 + Original exception for transient errors (5xx, 408, 429, network blips)
71 + or other exceptions
72 + """
73 + if isinstance(exc, aiohttp.ClientResponseError):
74 + if exc.status in (408, 429):
75 + return exc
76 + if exc.status < 500:
77 + return VLLMUnprocessableEntityError(
78 + f"Failed to fetch media from URL: HTTP {exc.status} error",
79 + parameter="image_url",
80 + value=url,
81 + )
82 + return exc
83 +
84 + if isinstance(exc, requests.exceptions.HTTPError):
85 + if exc.response is not None:
86 + status_code = exc.response.status_code
87 + if status_code in (408, 429):
88 + return exc
89 + if status_code < 500:
90 + return VLLMUnprocessableEntityError(
91 + f"Failed to fetch media from URL: HTTP {status_code} error",
92 + parameter="image_url",
93 + value=url,
94 + )
95 + return exc
96 +
97 + if isinstance(exc, requests.exceptions.InvalidURL):
98 + return VLLMUnprocessableEntityError(
99 + "Failed to fetch media from URL: Invalid URL format",
100 + parameter="image_url",
101 + value=url,
102 + )
103 +
104 + if isinstance(exc, ValueError):
105 + return VLLMUnprocessableEntityError(
106 + "Failed to fetch media from URL: Invalid URL",
107 + parameter="image_url",
108 + value=url,
109 + )
110 + return exc
111 +
112 +
113 def merge_media_io_kwargs(
114 defaults: dict[str, dict[str, Any]] | None,
115 overrides: dict[str, dict[str, Any]] | None,
@@ -303,11 +365,17 @@ class MediaConnector:
365 return media_io.load_bytes(cached)
366
367 connection = self.connection
306 - data = connection.get_bytes(
307 - url_spec.url,
308 - timeout=fetch_timeout,
309 - allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
310 - )
368 + try:
369 + data = connection.get_bytes(
370 + url_spec.url,
371 + timeout=fetch_timeout,
372 + allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
373 + )
374 + except Exception as e:
375 + wrapped = _wrap_media_fetch_error(url, e)
376 + if isinstance(wrapped, VLLMUnprocessableEntityError):
377 + raise wrapped from e
378 + raise
379
380 self._put_cached_bytes(url, data)
381 return media_io.load_bytes(data)
@@ -348,11 +416,17 @@ class MediaConnector:
416 return await future
417
418 connection = self.connection
351 - data = await connection.async_get_bytes(
352 - url_spec.url,
353 - timeout=fetch_timeout,
354 - allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
355 - )
419 + try:
420 + data = await connection.async_get_bytes(
421 + url_spec.url,
422 + timeout=fetch_timeout,
423 + allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
424 + )
425 + except Exception as e:
426 + wrapped = _wrap_media_fetch_error(url, e)
427 + if isinstance(wrapped, VLLMUnprocessableEntityError):
428 + raise wrapped from e
429 + raise
430
431 await loop.run_in_executor(
432 global_thread_pool, self._put_cached_bytes, url, data