feat: add SourceNotConfiguredError and the governance backend reason
Pure addition ahead of the reparenting, so this commit leaves every existing caller and test untouched. SourceNotConfiguredError deliberately stays outside GatewayUnavailableError: a source name that is not in the limiter's config dict is an assembly defect, not a transient outage, and folding it into the retryable family would let a typo retry forever without ever reaching a dead letter queue. The retry_after_s default is 5.0 rather than 0 because a backlog released at zero delay would stampede a backend that is already down.
This commit is contained in:
@@ -17,6 +17,7 @@ from polygateway.errors import (
|
|||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
|
SourceNotConfiguredError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.ocr import OcrClient
|
from polygateway.ocr import OcrClient
|
||||||
@@ -58,6 +59,7 @@ __all__ = [
|
|||||||
"ResultInvalidError",
|
"ResultInvalidError",
|
||||||
"SourceConfig",
|
"SourceConfig",
|
||||||
"SourceDeadError",
|
"SourceDeadError",
|
||||||
|
"SourceNotConfiguredError",
|
||||||
"TransientError",
|
"TransientError",
|
||||||
"__version__",
|
"__version__",
|
||||||
"gather_bounded",
|
"gather_bounded",
|
||||||
|
|||||||
@@ -5,8 +5,22 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SCOPE_REASONS = frozenset(
|
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(
|
SOURCE_REASONS = frozenset(
|
||||||
{
|
{
|
||||||
"network_error",
|
"network_error",
|
||||||
@@ -127,5 +141,14 @@ class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1
|
|||||||
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceNotConfiguredError(PolyGatewayError):
|
||||||
|
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
|
||||||
|
|
||||||
|
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是"配置写
|
||||||
|
错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置错误的任务永远
|
||||||
|
重投、永不告警——正是 issue #7 要修的那个 bug 的镜像(§3.4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class GovernanceBackendError(PolyGatewayError):
|
class GovernanceBackendError(PolyGatewayError):
|
||||||
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
|
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
|
GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
||||||
|
SCOPE_REASONS,
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
CircuitOpenError,
|
CircuitOpenError,
|
||||||
GatewayUnavailableError,
|
GatewayUnavailableError,
|
||||||
@@ -11,6 +13,7 @@ from polygateway.errors import (
|
|||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
|
SourceNotConfiguredError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,3 +92,36 @@ class TestBackendFailure:
|
|||||||
exc = GovernanceBackendError("redis down")
|
exc = GovernanceBackendError("redis down")
|
||||||
assert isinstance(exc, PolyGatewayError)
|
assert isinstance(exc, PolyGatewayError)
|
||||||
assert not isinstance(exc, TransientError)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user