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

@@ -52,6 +52,19 @@ def _is_retryable(exc: BaseException) -> bool:
return isinstance(exc, AppError) and exc.code == ErrorCode.RATE_LIMITED
# 持续性鉴权错误状态码:坏 key / 被禁用 → 每次重打都失败应计入熔断P1-2
_PERSISTENT_AUTH_STATUSES = frozenset({401, 403})
def _is_persistent_auth_error(exc: BaseException) -> bool:
"""持续性鉴权失败401/403错误 key / 账号被禁,重打无意义 → 计入熔断。
按 `status_code` 属性识别(不硬依赖任何厂商 SDK 异常类型)。
"""
status = getattr(exc, "status_code", None)
return isinstance(status, int) and status in _PERSISTENT_AUTH_STATUSES
def _input_len(req: LlmRequest) -> int:
if isinstance(req.input, str):
return len(req.input)
@@ -187,7 +200,7 @@ class Gateway:
project_id=str(req.scope.project_id) if req.scope.project_id else None,
)
async def _retrying(self) -> AsyncRetrying:
def _retrying(self) -> AsyncRetrying:
return AsyncRetrying(
stop=stop_after_attempt(self._max_retries + 1),
wait=wait_exponential(min=_RETRY_MIN_SECONDS, max=_RETRY_MAX_SECONDS),
@@ -212,6 +225,10 @@ class Gateway:
result = await self._complete_with_retry(adapter, req, route.model)
except Exception as exc: # noqa: BLE001 — 链内逐 provider 兜底,最终统一上抛
if not _is_retryable(exc):
# 持续性鉴权失败401/403虽不可重试但坏 key 应触发熔断P1-2
# 否则每次都白打同一坏 provider。其它不可重试错误直接上抛。
if _is_persistent_auth_error(exc):
self._breaker.record_failure(route.provider)
raise
self._breaker.record_failure(route.provider)
last_error = exc
@@ -240,7 +257,7 @@ class Gateway:
async def _complete_with_retry(
self, adapter: ProviderAdapter, req: LlmRequest, model: str
) -> ProviderResult:
retrying = await self._retrying()
retrying = self._retrying()
async for attempt in retrying:
with attempt:
return await adapter.complete(req, model)