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:
@@ -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,
|
||||
|
||||
@@ -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/5xx(Gemini 用 `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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user