fix(gateway): 熔断计入持续 4xx + 去 assert + Protocol/transient 去重

P1-2 非可重试错误(持续 401/403)也 record_failure,坏 key 可触发熔断。
P1-9 _complete_structured 的 assert 改显式 raise ValueError(-O 安全)。
P2 GatewayRun 抽到 orchestrator/_protocols.py 单点(去 4 处重复);
  _is_transient 抽到 adapters/base.py is_transient_by_name(去 3 处重复);
  Gemini Protocol 改 async def;gateway._retrying 去无用 async。
This commit is contained in:
Yaojia Wang
2026-06-21 19:32:49 +02:00
parent f7004e8d74
commit 016509c5c6
15 changed files with 178 additions and 65 deletions

View File

@@ -16,7 +16,13 @@ from pydantic import BaseModel
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
from .base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
is_transient_by_name,
)
if TYPE_CHECKING:
from anthropic import AsyncAnthropic
@@ -44,11 +50,7 @@ class StructuredAnthropic(Protocol):
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
return is_transient_by_name(exc, _TRANSIENT_NAMES)
def _system_blocks(req: LlmRequest) -> list[dict[str, Any]]:
@@ -144,7 +146,8 @@ class AnthropicAdapter:
return ProviderResult(text=_text_from(resp), usage=usage)
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
assert req.output_schema is not None
if req.output_schema is None:
raise ValueError("_complete_structured called without output_schema")
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,

View File

@@ -12,6 +12,34 @@ from pydantic import BaseModel, ConfigDict
from ..types import LlmRequest
# 瞬时(可退避重试)的 HTTP 状态码判定429 限流 或 5xx 服务端错误。
_RATE_LIMITED_STATUS = 429
_SERVER_ERROR_MIN_STATUS = 500
def _is_transient_status(status: object) -> bool:
return isinstance(status, int) and (
status == _RATE_LIMITED_STATUS or status >= _SERVER_ERROR_MIN_STATUS
)
def is_transient_by_name(
exc: Exception, names: frozenset[str], *, extra_codes: bool = False
) -> bool:
"""统一的瞬时错误判定DRY三个适配器共用
① 异常类名命中 `names`(按名匹配,避免硬依赖各厂商 SDK 异常类型);或
② `status_code` 属性是 429/5xx
③ `extra_codes=True` 时额外检查 `code` 属性是否为 429/5xxGemini 用 `exc.code`)。
"""
if type(exc).__name__ in names:
return True
if _is_transient_status(getattr(exc, "status_code", None)):
return True
if extra_codes and _is_transient_status(getattr(exc, "code", None)):
return True
return False
class Capabilities(BaseModel):
structured_output: bool = False

View File

@@ -14,7 +14,13 @@ from typing import Any, Protocol
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
from .base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
is_transient_by_name,
)
_TRANSIENT_NAMES = frozenset(
{
@@ -31,7 +37,7 @@ _TRANSIENT_NAMES = frozenset(
class GeminiModels(Protocol):
async def generate_content(self, **kwargs: Any) -> Any: ...
def generate_content_stream(self, **kwargs: Any) -> Any: ...
async def generate_content_stream(self, **kwargs: Any) -> Any: ...
class GeminiAio(Protocol):
@@ -45,11 +51,8 @@ class GeminiClient(Protocol):
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "code", None) or getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
# Gemini SDK 部分错误用 `exc.code` 携带状态码 → extra_codes=True 一并检查。
return is_transient_by_name(exc, _TRANSIENT_NAMES, extra_codes=True)
def _contents(req: LlmRequest) -> str:

View File

@@ -15,7 +15,13 @@ from pydantic import BaseModel
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
from .base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
is_transient_by_name,
)
# OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK
_TRANSIENT_NAMES = frozenset(
@@ -30,11 +36,7 @@ _TRANSIENT_NAMES = frozenset(
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
return is_transient_by_name(exc, _TRANSIENT_NAMES)
class StructuredClient(Protocol):
@@ -126,7 +128,8 @@ class OpenAICompatAdapter:
return ProviderResult(text=text, usage=_usage_from(resp.usage))
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
assert req.output_schema is not None
if req.output_schema is None:
raise ValueError("_complete_structured called without output_schema")
parsed, raw = await self._structured().create_with_completion(
messages=_messages(req),
response_model=req.output_schema,