"""errors.py 错误四分类与 scope 级不可用语义测试(M1 设计 §3)。""" import pytest from polygateway.errors import ( AllSourcesExhausted, CircuitOpenError, GatewayUnavailableError, GovernanceBackendError, PolyGatewayError, RequestRejectedError, ResultInvalidError, SourceDeadError, 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") assert isinstance(exc, PolyGatewayError) assert not isinstance(exc, TransientError)