Prhub

#31417 Return 400 instead of 500 for unfetchable or unparseable multimodal inputs

原始 PR 作者 gongy 合并时间 2026-07-28 20:23 文件变更 4 提交数 4 评论 5 代码增减 +138 / -8

执行摘要

多模态输入获取 / 解码失败时返回 400 而非 500

当前多模态请求中无法获取或解码的 media URL 或字节会引发 500 InternalServerError,这不符合 OpenAI API 语义(应为 invalid_request_error,400),且污染服务器错误监控。生产环境遇到媒体主机间歇性 404/500 导致大量 SGLang 500,无法与真实服务器故障区分。PR body 指出:'These are client-payload errors, not server faults: by OpenAI API semantics they should be invalid_request_error (400).'

该 PR 是教科书式的错误分类修复,值得精读:通过集中异常元组 CLIENT_MEDIA_EXCEPTIONS 实现跨组件错误分类一致性,并通过 mock 测试全面覆盖非功能性路径。设计思路适用于其他需要区分客户端错误和服务端错误的场景。

讨论亮点

无代码审查讨论。合并者通过 CI 触发测试 test_base_processor_bad_input.pytest_base_processor_image_decode.py,两次运行均通过,验证了变更正确性。

实现拆解

  1. 定义集中异常元组:在 python/sglang/srt/utils/common.py 中导入 UnidentifiedImageError,新增 CLIENT_MEDIA_EXCEPTIONS 元组,包含 ValueErrorUnidentifiedImageErrorrequests.exceptions.RequestException。同时修改 load_audioload_video 函数,将特定解码异常(如 sf.LibsndfileErrortorchcodec 解码错误)捕获并重新抛出为 ValueError,确保它们被纳入客户端错误分类。
  2. 改造 BaseMultimodalProcessor._load_single_item:在 python/sglang/srt/multimodal/processors/base_processor.py 中,将原有的 except ValueError 替换为 except CLIENT_MEDIA_EXCEPTIONS,使不可获取或不可解码的媒体数据统一抛出 ValueError,其他异常仍抛出 RuntimeError
  3. 改造编码服务器 _load_single_item:在 python/sglang/srt/disaggregation/encode_server.py 中新增 except CLIENT_MEDIA_EXCEPTIONS 分支,将其转换为 BadRequestError(而非 ValueError),以适应 DP 环境中的分类机制。
  4. 新增单元测试:创建 test/registered/unit/multimodal/test_base_processor_bad_input.py,使用 _StubProcessor 和 mock 全面覆盖:不可获取的 URL(HTTPError/ConnectionError/Timeout)、无效 base64、不可解码的图像/音频/视频字节;同时验证真正的服务器错误(ImportError/MemoryError)仍保持 RuntimeError,并测试 CLIENT_MEDIA_EXCEPTIONS 元组覆盖正确。
文件 模块 状态 重要度
python/sglang/srt/multimodal/processors/base_processor.py 多模态处理器 modified 5.63
python/sglang/srt/utils/common.py 基础工具 modified 6.04
python/sglang/srt/disaggregation/encode_server.py 编码服务器 modified 5.23
test/registered/unit/multimodal/test_base_processor_bad_input.py 测试 added 7.15

关键符号

BaseMultimodalProcessor._load_single_item EncodeServer._load_single_item load_audio load_video CLIENT_MEDIA_EXCEPTIONS

关键源码片段

python/sglang/srt/multimodal/processors/base_processor.py core-logic

核心异常分类逻辑:将 except ValueError 替换为 except CLIENT_MEDIA_EXCEPTIONS,统一客户端错误处理。

# python/sglang/srt/multimodal/processors/base_processor.py (partial)
from sglang.srt.utils import CLIENT_MEDIA_EXCEPTIONS # 新增导入,定义见 common.py@classmethod
def _load_single_item(cls, data, modality, frame_count_limit=None,
                      audio_sample_rate=None, discard_alpha_channel=True):
    """加载单个多模态数据,若为预计算结果则直接返回。类方法,支持 pickle 用于多进程。"""
    if cls._is_preprocessed_input(data):
        return data
    try:
        if modality == Modality.IMAGE:
            img, _ = load_image(data, cls.gpu_image_decode)
            if isinstance(img, torch.Tensor):
                return img
            if discard_alpha_channel and img.mode != "RGB":
                img = img.convert("RGB")
            img.load() # 在 I/O worker 中立即解码
            return img
        elif modality == Modality.VIDEO:
            return load_video(data, frame_count_limit)
        elif modality == Modality.AUDIO:
            return load_audio(data, audio_sample_rate)
    except CLIENT_MEDIA_EXCEPTIONS as e:
        # 客户端错误(无法获取 URL / 无法解码媒体)-> 抛出 ValueError,服务层返回 400
        data_str = str(data)
        if len(data_str) > 100:
            data_str = data_str[:100] + "..."
        raise ValueError(f"Error while loading data {data_str}: {e}") from e
    except Exception as e:
        # 真正的服务器端故障 -> 保持 RuntimeError,最终返回 500
        data_str = str(data)
        if len(data_str) > 100:
            data_str = data_str[:100] + "..."
        raise RuntimeError(f"Error while loading data {data_str}: {e}") from e
test/registered/unit/multimodal/test_base_processor_bad_input.py test-coverage

新增全面的单元测试,验证所有客户端错误场景返回 ValueError,服务器错误保持 RuntimeError。

# test/registered/unit/multimodal/test_base_processor_bad_input.pyclass _StubProcessor(BaseMultimodalProcessor):
    # 使用 CPU 解码,无需 GPU,仅测试 _load_single_item classmethod
    gpu_image_decode = Falseclass TestBadInputIsClientError(CustomTestCase):
    def _assert_client_error(self, data, modality):
        # 断言客户端错误引发 ValueError
        with self.assertRaises(ValueError):
            _StubProcessor._load_single_item(data, modality)
​
    def test_unfetchable_url_every_modality(self):
        # 模拟 HTTPError / ConnectionError / Timeout,它们都是 RequestException 子类
        for exc in (
            requests.exceptions.HTTPError("404 from media host"),
            requests.exceptions.ConnectionError("dns failure"),
            requests.exceptions.Timeout("read timed out"),
        ):
            for modality in MODALITIES: # IMAGE / AUDIO / VIDEO
                with self.subTest(exc=type(exc).__name__, modality=modality):
                    with patch("sglang.srt.utils.common.get_mm_http_session",
                               return_value=_session_raising(exc)):
                        self._assert_client_error("https://media.host/clip", modality)
​
    def test_invalid_base64(self):
        self._assert_client_error("!!!not-base64!!!", Modality.IMAGE)
​
    def test_undecodable_image_bytes(self):
        # PIL 抛出 UnidentifiedImageError(OSError 子类),不是 ValueError
        self._assert_client_error(b"definitely not an image", Modality.IMAGE)
​
    def test_undecodable_audio_bytes(self):
        # soundfile 抛出 LibsndfileError(RuntimeError 子类),不是 ValueError
        self._assert_client_error(b"definitely not audio", Modality.AUDIO)
​
    def test_undecodable_video_bytes(self):
        # 模拟解码器抛出 RuntimeError(如 "invalid data found")
        with patch("sglang.srt.utils.common.VideoDecoderWrapper",
                   side_effect=RuntimeError("invalid data found when processing input")):
            self._assert_client_error(b"definitely not a video", Modality.VIDEO)

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

风险较低。主要风险在于 CLIENT_MEDIA_EXCEPTIONS 可能遗漏某些客户端错误类型,但 RequestException 包含了 HTTPErrorConnectionErrorTimeout 等常见子类,UnidentifiedImageError 覆盖 PIL 解码失败,ValueError 覆盖无效 base64;load_audioload_video 中新增的转换排除了 ImportError/MemoryError,避免掩盖服务器故障。兼容性方面,依赖 500 状态码的客户端需调整,但此次变更符合 API 规范,属于正确行为修正。

影响所有多模态推理请求的输入校验。用户:无效输入将收到 400 状态码和更清晰的错误信息,而非 500。系统:减少错误监控中的误报,提升可观测性。团队:异常分类更清晰,便于快速定位问题。测试覆盖全面,回归风险低。

客户端兼容性 异常分类完整性

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论