diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index cef3568..56b26cf 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -17,6 +17,7 @@ from polygateway.errors import ( RequestRejectedError, ResultInvalidError, SourceDeadError, + SourceNotConfiguredError, TransientError, ) from polygateway.ocr import OcrClient @@ -58,6 +59,7 @@ __all__ = [ "ResultInvalidError", "SourceConfig", "SourceDeadError", + "SourceNotConfiguredError", "TransientError", "__version__", "gather_bounded", diff --git a/src/polygateway/errors.py b/src/polygateway/errors.py index 720a903..d2cae87 100644 --- a/src/polygateway/errors.py +++ b/src/polygateway/errors.py @@ -5,8 +5,22 @@ """ SCOPE_REASONS = frozenset( - {"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"} + { + "circuit_open", + "retry_exhausted", + "stalled", + "quota_exhausted", + "no_sources", + "governance_backend_down", # issue #7: 限流/熔断后端故障(fail-closed → 整个 scope 发不出请求) + } ) +GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0 +"""治理后端故障的建议重投间隔(秒)。 + +**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻), +故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让积压任务零延迟 +同时冲击已挂掉的后端,把一次故障放大成一场风暴(issue #7 §3.2)。 +""" SOURCE_REASONS = frozenset( { "network_error", @@ -127,5 +141,14 @@ class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1 """重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。""" +class SourceNotConfiguredError(PolyGatewayError): + """源名不在限流后端的配置字典中: 装配缺陷,正常不可达。 + + **有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是"配置写 + 错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置错误的任务永远 + 重投、永不告警——正是 issue #7 要修的那个 bug 的镜像(§3.4)。 + """ + + class GovernanceBackendError(PolyGatewayError): """限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。""" diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 15719ee..c0aff00 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -3,6 +3,8 @@ import pytest from polygateway.errors import ( + GOVERNANCE_BACKEND_RETRY_AFTER_S, + SCOPE_REASONS, AllSourcesExhausted, CircuitOpenError, GatewayUnavailableError, @@ -11,6 +13,7 @@ from polygateway.errors import ( RequestRejectedError, ResultInvalidError, SourceDeadError, + SourceNotConfiguredError, TransientError, ) @@ -89,3 +92,36 @@ class TestBackendFailure: exc = GovernanceBackendError("redis down") assert isinstance(exc, PolyGatewayError) assert not isinstance(exc, TransientError) + + +class TestSourceNotConfigured: + """装配缺陷有意留在 scope 级家族之外(issue #7 §3.4,Q1 人类拍板)。""" + + def test_is_domain_error_but_not_scope_level(self): + exc = SourceNotConfiguredError("未知源 'nope'(scope=llm)") + assert isinstance(exc, PolyGatewayError) + # 关键断言: 归入可重投家族会让配置写错的任务永远重投、永不进死信 + assert not isinstance(exc, GatewayUnavailableError) + + def test_exported_at_package_top_level(self): + import polygateway + + assert polygateway.SourceNotConfiguredError is SourceNotConfiguredError + assert "SourceNotConfiguredError" in polygateway.__all__ + + +class TestGovernanceBackendReason: + """新 scope 级 reason 值域(issue #7 §3.1)。""" + + def test_reason_admitted_to_scope_domain(self): + assert "governance_backend_down" in SCOPE_REASONS + + def test_gateway_unavailable_accepts_the_new_reason(self): + exc = AllSourcesExhausted( + scope="LLM", reason="governance_backend_down", retry_after_s=0.0 + ) + assert exc.reason == "governance_backend_down" + + def test_retry_after_default_is_non_zero(self): + """取 0 会让积压任务零延迟冲击已挂掉的后端(§3.2)。""" + assert GOVERNANCE_BACKEND_RETRY_AFTER_S > 0