58061c7536
P6 round 2 showed routing convergence turns account-level 429s into the binding constraint (62% throttle rate at full concurrency). Each source now carries a local AIMD limit: multiplicative cut on 429, additive growth on success. Over-limit picks queue via the existing quota-wait poll instead of burning retry budget or tripping the circuit-open verdict.
132 lines
4.3 KiB
Python
132 lines
4.3 KiB
Python
"""错误四分类与 scope 级不可用语义(M1 设计 §3;ARCH §6)。
|
|
|
|
分类决定治理行为(重试/换源/熔断计数),库内禁止绕过分类做 ad-hoc 判断。
|
|
构造形态承 CHS `app/domain/errors.py` 的 ProviderError 一族。
|
|
"""
|
|
|
|
SCOPE_REASONS = frozenset(
|
|
{"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"}
|
|
)
|
|
SOURCE_REASONS = frozenset(
|
|
{
|
|
"network_error",
|
|
"timeout",
|
|
"rate_limited",
|
|
"source_dead",
|
|
"circuit_open",
|
|
"cooldown",
|
|
"adaptive_paced", # M2.5 §3.35: AIMD 超限排队(quota-wait 通道)
|
|
}
|
|
)
|
|
|
|
|
|
class PolyGatewayError(Exception):
|
|
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
source_name: str | None = None,
|
|
status_code: int | None = None,
|
|
operation: str | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.source_name = source_name
|
|
self.status_code = status_code
|
|
self.operation = operation
|
|
|
|
|
|
class TransientError(PolyGatewayError):
|
|
"""瞬时错误(超时/5xx/429/网络抖动/SSE 异常): 退避后可重试、可换源、计熔断。"""
|
|
|
|
def __init__(self, message: str, *, retry_after_s: float | None = None, **kwargs) -> None:
|
|
super().__init__(message, **kwargs)
|
|
self.retry_after_s = retry_after_s
|
|
|
|
|
|
class SourceDeadError(PolyGatewayError):
|
|
"""源死亡(401/403/欠费): 不重试,立即换源,该源 force_open。"""
|
|
|
|
|
|
class RequestRejectedError(PolyGatewayError):
|
|
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。"""
|
|
|
|
|
|
class ResultInvalidError(PolyGatewayError):
|
|
"""坏结果 ≠ 坏服务: 调用成功但内容不可解析;熔断记成功,不入 transport 重试。"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
raw_text: str = "",
|
|
repair_error: str | None = None,
|
|
validation_errors: tuple[str, ...] = (),
|
|
**kwargs,
|
|
) -> None:
|
|
super().__init__(message, **kwargs)
|
|
self.raw_text = raw_text
|
|
self.repair_error = repair_error
|
|
self.validation_errors = tuple(validation_errors)
|
|
|
|
|
|
class GatewayUnavailableError(PolyGatewayError):
|
|
"""scope 级暂时不可用;业务侧 catch 本类做延期重投(CHS arq 模式)。
|
|
|
|
`retry_after_s` 非可选(0 = 可立即重试),承 CHS ProviderUnavailableError。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
scope: str,
|
|
reason: str,
|
|
retry_after_s: float,
|
|
per_source_reasons: dict[str, str] | None = None,
|
|
source_name: str | None = None,
|
|
) -> None:
|
|
if not scope.strip():
|
|
raise ValueError("scope 不能为空")
|
|
if reason not in SCOPE_REASONS:
|
|
raise ValueError(f"未知 scope 级 reason: {reason!r}(允许: {sorted(SCOPE_REASONS)})")
|
|
if retry_after_s < 0:
|
|
raise ValueError("retry_after_s 不能为负")
|
|
reasons = dict(per_source_reasons or {})
|
|
for src, src_reason in reasons.items():
|
|
if src_reason not in SOURCE_REASONS:
|
|
raise ValueError(
|
|
f"源 {src!r} 的 reason 非法: {src_reason!r}(允许: {sorted(SOURCE_REASONS)})"
|
|
)
|
|
super().__init__(f"{scope.lower()} 网关暂时不可用: {reason}", source_name=source_name)
|
|
self.scope = scope.lower()
|
|
self.reason = reason
|
|
self.retry_after_s = retry_after_s
|
|
self.per_source_reasons = reasons
|
|
|
|
|
|
class CircuitOpenError(GatewayUnavailableError):
|
|
"""全部候选源被熔断门拒绝;reason 恒为 circuit_open。"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
scope: str,
|
|
retry_after_s: float,
|
|
per_source_reasons: dict[str, str] | None = None,
|
|
) -> None:
|
|
super().__init__(
|
|
scope=scope,
|
|
reason="circuit_open",
|
|
retry_after_s=retry_after_s,
|
|
per_source_reasons=per_source_reasons,
|
|
)
|
|
|
|
|
|
class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1 冻结的公共名
|
|
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
|
|
|
|
|
class GovernanceBackendError(PolyGatewayError):
|
|
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
|