45073486a7
A fail-closed limiter or breaker backend means the scope cannot emit a single request, which is exactly scope-level unavailability. But the error sat directly under PolyGatewayError, so a caller writing only `except GatewayUnavailableError` dropped it into the catch-all branch: Redis blips once and a backlog of tasks burns its business failure budget into the dead letter queue, over a fault a restart would clear. Three gate paths leak to callers rather than being absorbed by _record_quietly (try_acquire, try_enter, progress_age_s); each is now pinned by a test, since none of them had one before. The two unknown-source sites move to SourceNotConfiguredError instead of following along. They report a misconfigured source name, not an outage, and letting them into the retryable family would be the mirror of the bug being fixed here: the task would retry forever and never surface.
145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
"""errors.py 错误四分类与 scope 级不可用语义测试(M1 设计 §3)。"""
|
|
|
|
import pytest
|
|
|
|
from polygateway.errors import (
|
|
GOVERNANCE_BACKEND_RETRY_AFTER_S,
|
|
SCOPE_REASONS,
|
|
AllSourcesExhausted,
|
|
CircuitOpenError,
|
|
GatewayUnavailableError,
|
|
GovernanceBackendError,
|
|
PolyGatewayError,
|
|
RequestRejectedError,
|
|
ResultInvalidError,
|
|
SourceDeadError,
|
|
SourceNotConfiguredError,
|
|
TransientError,
|
|
)
|
|
|
|
|
|
class TestBaseShape:
|
|
def test_base_carries_source_context(self):
|
|
exc = PolyGatewayError("boom", source_name="qwen_1", status_code=502, operation="chat")
|
|
assert exc.source_name == "qwen_1"
|
|
assert exc.status_code == 502
|
|
assert exc.operation == "chat"
|
|
|
|
def test_four_way_taxonomy_inherits_base(self):
|
|
for cls in (TransientError, SourceDeadError, RequestRejectedError, ResultInvalidError):
|
|
assert issubclass(cls, PolyGatewayError)
|
|
|
|
def test_transient_retry_after_optional(self):
|
|
assert TransientError("429").retry_after_s is None
|
|
assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5
|
|
|
|
|
|
class TestResultInvalid:
|
|
def test_carries_diagnosis(self):
|
|
exc = ResultInvalidError(
|
|
"bad json",
|
|
raw_text="{oops",
|
|
repair_error="unterminated",
|
|
validation_errors=("field x missing",),
|
|
)
|
|
assert exc.raw_text == "{oops"
|
|
assert exc.repair_error == "unterminated"
|
|
assert exc.validation_errors == ("field x missing",)
|
|
|
|
|
|
class TestGatewayUnavailable:
|
|
def test_fields_and_inheritance(self):
|
|
exc = AllSourcesExhausted(
|
|
scope="LLM",
|
|
reason="retry_exhausted",
|
|
retry_after_s=4.0,
|
|
per_source_reasons={"qwen_1": "timeout"},
|
|
)
|
|
assert isinstance(exc, GatewayUnavailableError)
|
|
assert exc.scope == "llm" # CHS 同款: scope 归一化小写
|
|
assert exc.reason == "retry_exhausted"
|
|
assert exc.retry_after_s == 4.0
|
|
assert exc.per_source_reasons == {"qwen_1": "timeout"}
|
|
|
|
def test_circuit_open_reason_fixed(self):
|
|
exc = CircuitOpenError(scope="LLM", retry_after_s=30.0)
|
|
assert exc.reason == "circuit_open"
|
|
assert isinstance(exc, GatewayUnavailableError)
|
|
|
|
def test_scope_reason_domain_enforced(self):
|
|
with pytest.raises(ValueError):
|
|
AllSourcesExhausted(scope="LLM", reason="bad_reason", retry_after_s=0.0)
|
|
|
|
def test_per_source_reason_domain_enforced(self):
|
|
with pytest.raises(ValueError):
|
|
AllSourcesExhausted(
|
|
scope="LLM",
|
|
reason="no_sources",
|
|
retry_after_s=0.0,
|
|
per_source_reasons={"qwen_1": "weird"},
|
|
)
|
|
|
|
def test_retry_after_non_negative_and_scope_non_empty(self):
|
|
with pytest.raises(ValueError):
|
|
AllSourcesExhausted(scope="LLM", reason="stalled", retry_after_s=-1.0)
|
|
with pytest.raises(ValueError):
|
|
AllSourcesExhausted(scope=" ", reason="stalled", retry_after_s=0.0)
|
|
|
|
|
|
class TestBackendFailure:
|
|
def test_governance_backend_error_is_not_transient(self):
|
|
"""限流/熔断后端故障必须报错不放行,且不落入可重试分类。"""
|
|
exc = GovernanceBackendError("redis down", scope="llm")
|
|
assert isinstance(exc, PolyGatewayError)
|
|
assert not isinstance(exc, TransientError)
|
|
|
|
def test_is_scope_level_unavailability(self):
|
|
"""fail-closed 时整个 scope 一个请求都发不出去,调用方一条 except 应覆盖(issue #7)。"""
|
|
exc = GovernanceBackendError("限流后端 try_acquire 失败: boom", scope="LLM")
|
|
assert isinstance(exc, GatewayUnavailableError)
|
|
assert exc.reason == "governance_backend_down"
|
|
assert exc.scope == "llm" # 与既有 scope 级异常同款: 归一化小写
|
|
assert exc.retry_after_s == GOVERNANCE_BACKEND_RETRY_AFTER_S
|
|
|
|
def test_diagnostic_message_survives_reparenting(self):
|
|
"""父类把 message 覆写为模板串,而各构造点的诊断串是排障主线索(§3.5)。"""
|
|
exc = GovernanceBackendError("熔断后端 try_enter 失败: boom", scope="llm")
|
|
assert str(exc) == "熔断后端 try_enter 失败: boom"
|
|
|
|
def test_retry_after_overridable(self):
|
|
exc = GovernanceBackendError("redis down", scope="llm", retry_after_s=30.0)
|
|
assert exc.retry_after_s == 30.0
|
|
|
|
|
|
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
|