"""错误四分类与 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): """限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""