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
|
||||||
@@ -22,6 +22,7 @@ from .base import (
|
|||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
StreamChunk,
|
StreamChunk,
|
||||||
is_transient_by_name,
|
is_transient_by_name,
|
||||||
|
provider_error_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -131,7 +132,9 @@ class AnthropicAdapter:
|
|||||||
return await self._complete_text(req, model)
|
return await self._complete_text(req, model)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
raise TransientProviderError(
|
||||||
|
provider_error_summary(exc), provider=self.provider
|
||||||
|
) from exc
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||||
@@ -187,5 +190,7 @@ class AnthropicAdapter:
|
|||||||
yield StreamChunk(usage=_usage_from(usage))
|
yield StreamChunk(usage=_usage_from(usage))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
raise TransientProviderError(
|
||||||
|
provider_error_summary(exc), provider=self.provider
|
||||||
|
) from exc
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ def _is_transient_status(status: object) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def provider_error_summary(exc: Exception) -> str:
|
||||||
|
"""脱敏摘要:只含异常类名 + 可选状态码,绝不含原始厂商 HTTP 响应体(CR-M2-2)。
|
||||||
|
|
||||||
|
厂商异常的 `str(exc)` 常内嵌完整响应体(可能含请求内容/内部细节),不应进我们
|
||||||
|
构造并上抛的错误消息(防泄漏,§9.3)。完整细节仍可留在服务端日志(若已记)。
|
||||||
|
状态码优先取 `status_code`,回退取 `code`(Gemini 部分错误用 `exc.code`)。
|
||||||
|
"""
|
||||||
|
name = type(exc).__name__
|
||||||
|
status = getattr(exc, "status_code", None)
|
||||||
|
if not isinstance(status, int):
|
||||||
|
status = getattr(exc, "code", None)
|
||||||
|
if isinstance(status, int):
|
||||||
|
return f"{name} (status={status})"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
def is_transient_by_name(
|
def is_transient_by_name(
|
||||||
exc: Exception, names: frozenset[str], *, extra_codes: bool = False
|
exc: Exception, names: frozenset[str], *, extra_codes: bool = False
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .base import (
|
|||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
StreamChunk,
|
StreamChunk,
|
||||||
is_transient_by_name,
|
is_transient_by_name,
|
||||||
|
provider_error_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
_TRANSIENT_NAMES = frozenset(
|
_TRANSIENT_NAMES = frozenset(
|
||||||
@@ -103,7 +104,9 @@ class GeminiAdapter:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
raise TransientProviderError(
|
||||||
|
provider_error_summary(exc), provider=self.provider
|
||||||
|
) from exc
|
||||||
raise
|
raise
|
||||||
text = getattr(resp, "text", "") or ""
|
text = getattr(resp, "text", "") or ""
|
||||||
usage = _usage_from(getattr(resp, "usage_metadata", None))
|
usage = _usage_from(getattr(resp, "usage_metadata", None))
|
||||||
@@ -126,5 +129,7 @@ class GeminiAdapter:
|
|||||||
yield StreamChunk(usage=_usage_from(meta))
|
yield StreamChunk(usage=_usage_from(meta))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
raise TransientProviderError(
|
||||||
|
provider_error_summary(exc), provider=self.provider
|
||||||
|
) from exc
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .base import (
|
|||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
StreamChunk,
|
StreamChunk,
|
||||||
is_transient_by_name,
|
is_transient_by_name,
|
||||||
|
provider_error_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
# OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK)。
|
# OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK)。
|
||||||
@@ -115,7 +116,9 @@ class OpenAICompatAdapter:
|
|||||||
return await self._complete_text(req, model)
|
return await self._complete_text(req, model)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
raise TransientProviderError(
|
||||||
|
provider_error_summary(exc), provider=self.provider
|
||||||
|
) from exc
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||||
@@ -158,5 +161,7 @@ class OpenAICompatAdapter:
|
|||||||
yield StreamChunk(usage=_usage_from(chunk.usage))
|
yield StreamChunk(usage=_usage_from(chunk.usage))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
raise TransientProviderError(
|
||||||
|
provider_error_summary(exc), provider=self.provider
|
||||||
|
) from exc
|
||||||
raise
|
raise
|
||||||
|
|||||||
Reference in New Issue
Block a user