[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