fix(gateway): 适配器瞬时错误消息脱敏——只留类名+状态码不泄漏厂商响应体(CR-M2-2)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"""适配器错误脱敏单测(CR-M2-2:上抛消息不得内嵌厂商 HTTP 响应体)。
|
||||
|
||||
厂商异常的 `str(exc)` 常携带完整响应体(可能含请求内容/内部细节)。适配器把瞬时
|
||||
故障包装成 `TransientProviderError` 时,消息应只含**异常类名 + 状态码**,绝不含原始
|
||||
响应体(防泄漏,§9.3)。完整细节仍可留在服务端日志。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from ww_llm_gateway.adapters.base import provider_error_summary
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
from ww_llm_gateway.errors import TransientProviderError
|
||||
from ww_llm_gateway.types import LlmRequest, Scope
|
||||
|
||||
_SECRET = "SECRET_BODY_MARKER_xyz"
|
||||
|
||||
|
||||
def _req(**kw: Any) -> LlmRequest:
|
||||
kw.setdefault("tier", "writer")
|
||||
kw.setdefault("input", "x")
|
||||
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
|
||||
|
||||
|
||||
class _LeakyRateLimit(Exception):
|
||||
"""底层厂商异常:`str()` 内嵌响应体,且带 429 状态码(触发瞬时判定)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(f'HTTP 429 {{"error": "{_SECRET}"}}')
|
||||
self.status_code = 429
|
||||
|
||||
|
||||
def test_provider_error_summary_omits_body_keeps_class_and_status() -> None:
|
||||
summary = provider_error_summary(_LeakyRateLimit())
|
||||
|
||||
assert _SECRET not in summary
|
||||
assert "_LeakyRateLimit" in summary
|
||||
assert "429" in summary
|
||||
|
||||
|
||||
def test_provider_error_summary_class_only_when_no_status() -> None:
|
||||
class Boom(Exception):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_SECRET)
|
||||
|
||||
summary = provider_error_summary(Boom())
|
||||
|
||||
assert _SECRET not in summary
|
||||
assert summary == "Boom"
|
||||
|
||||
|
||||
class _RaisingCompletions:
|
||||
def __init__(self, *, stream: bool) -> None:
|
||||
self._stream = stream
|
||||
|
||||
async def create(self, **kw: Any) -> Any:
|
||||
raise _LeakyRateLimit()
|
||||
|
||||
|
||||
def _adapter(*, stream: bool) -> OpenAICompatAdapter:
|
||||
client = SimpleNamespace(chat=SimpleNamespace(completions=_RaisingCompletions(stream=stream)))
|
||||
return OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, client))
|
||||
|
||||
|
||||
async def test_complete_wraps_transient_without_leaking_body() -> None:
|
||||
adapter = _adapter(stream=False)
|
||||
|
||||
with pytest.raises(TransientProviderError) as exc_info:
|
||||
await adapter.complete(_req(), "deepseek-chat")
|
||||
|
||||
msg = exc_info.value.message
|
||||
assert _SECRET not in msg
|
||||
assert "_LeakyRateLimit" in msg
|
||||
assert "429" in msg
|
||||
|
||||
|
||||
async def test_stream_wraps_transient_without_leaking_body() -> None:
|
||||
adapter = _adapter(stream=True)
|
||||
|
||||
with pytest.raises(TransientProviderError) as exc_info:
|
||||
async for _ in adapter.stream(_req(), "deepseek-chat"):
|
||||
pass
|
||||
|
||||
msg = exc_info.value.message
|
||||
assert _SECRET not in msg
|
||||
assert "_LeakyRateLimit" in msg
|
||||
assert "429" in msg
|
||||
Reference in New Issue
Block a user