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:
@@ -108,6 +108,22 @@ def transient(msg: str = "boom") -> TransientProviderError:
|
||||
return TransientProviderError(msg)
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""模拟持续性鉴权失败(坏 key / 账号被禁):带 `status_code`,**非**瞬时不可重试。
|
||||
|
||||
适配器对 401/403 不翻译为 `TransientProviderError`,原样上抛;网关须对其计入熔断
|
||||
(P1-2)而非每次白打同一坏 provider。
|
||||
"""
|
||||
|
||||
def __init__(self, msg: str = "unauthorized", *, status_code: int = 401) -> None:
|
||||
super().__init__(msg)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def auth_error(status_code: int = 401) -> AuthError:
|
||||
return AuthError(status_code=status_code)
|
||||
|
||||
|
||||
def chain(*routes: tuple[str, str]) -> list[Route]:
|
||||
return [Route(provider=p, model=m) for p, m in routes]
|
||||
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
|
||||
from fakes_resilience import (
|
||||
AuthError,
|
||||
FakeLedger,
|
||||
ScriptedAdapter,
|
||||
auth_error,
|
||||
chain,
|
||||
chain_resolver,
|
||||
transient,
|
||||
)
|
||||
from ww_llm_gateway.gateway import CircuitBreaker, Gateway
|
||||
from ww_llm_gateway.types import LlmRequest
|
||||
from ww_shared import AppError, ErrorCode
|
||||
@@ -154,6 +162,39 @@ def test_circuit_breaker_reopens_after_cooldown() -> None:
|
||||
assert cb.is_open("deepseek") is False
|
||||
|
||||
|
||||
async def test_persistent_auth_error_counts_toward_breaker(req: LlmRequest) -> None:
|
||||
"""P1-2:持续性 401(坏 key)虽不可重试,但应计入熔断——连续 N 次后熔断打开。
|
||||
|
||||
每次 `run` 命中 401 立即上抛(不重试、不回退),但 raise 前 `record_failure`;
|
||||
达到阈值后熔断打开,后续请求直接跳过该 provider → 链耗尽抛 LLM_UNAVAILABLE。
|
||||
"""
|
||||
threshold = 3
|
||||
primary = ScriptedAdapter("deepseek", failures=[auth_error() for _ in range(threshold + 2)])
|
||||
cb = CircuitBreaker(threshold=threshold, reset_seconds=60.0)
|
||||
gw = Gateway(
|
||||
{"deepseek": primary},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"))),
|
||||
breaker=cb,
|
||||
)
|
||||
|
||||
# 前 threshold 次:401 原样上抛(不可重试),但每次计一次熔断失败。
|
||||
for _ in range(threshold):
|
||||
assert cb.is_open("deepseek") is False
|
||||
with pytest.raises(AuthError):
|
||||
await gw.run(req)
|
||||
|
||||
# 第 threshold 次失败后熔断打开。
|
||||
assert cb.is_open("deepseek") is True
|
||||
|
||||
# 后续请求:provider 被熔断跳过 → 不再调 complete,链耗尽 → LLM_UNAVAILABLE。
|
||||
calls_before = primary.complete_calls
|
||||
with pytest.raises(AppError) as exc:
|
||||
await gw.run(req)
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
assert primary.complete_calls == calls_before # 熔断后未再触达坏 provider
|
||||
|
||||
|
||||
async def test_open_circuit_skips_provider(req: LlmRequest) -> None:
|
||||
# 熔断已打开的主 provider 被直接跳过,连 complete 都不调,直接走回退。
|
||||
primary = ScriptedAdapter("deepseek", text="不该被调")
|
||||
|
||||
Reference in New Issue
Block a user