feat: add frozen core types and error taxonomy
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""types.py 冻结签名的行为测试(M1 设计 §2)。"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
BreakerConfig,
|
||||
ChatRequest,
|
||||
GlobalLimits,
|
||||
LLMResponse,
|
||||
RetryPolicy,
|
||||
SourceConfig,
|
||||
TransportResult,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
||||
def _make_source(**overrides):
|
||||
"""构造最小合法 SourceConfig,单点覆盖便于逐条触发不变式。"""
|
||||
base = dict(
|
||||
name="qwen_1",
|
||||
provider="qwen",
|
||||
base_url="https://gw.example/v1",
|
||||
api_key="sk-test",
|
||||
model="qwen-max",
|
||||
timeout_s=120.0,
|
||||
)
|
||||
base.update(overrides)
|
||||
return SourceConfig(**base)
|
||||
|
||||
|
||||
class TestLLMResponse:
|
||||
def test_eleven_legacy_fields_positional(self):
|
||||
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
|
||||
resp = LLMResponse(
|
||||
"content", "thinking", "qwen-max", "qwen", 10, 20, 300, 50.0, 80.0, False, "cid"
|
||||
)
|
||||
assert resp.content == "content"
|
||||
assert resp.call_id == "cid"
|
||||
|
||||
def test_new_fields_have_defaults(self):
|
||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||
assert resp.source_name == ""
|
||||
assert resp.cost is None
|
||||
assert resp.usage_source == "measured"
|
||||
assert resp.structured_data is None
|
||||
|
||||
def test_frozen(self):
|
||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
resp.content = "x"
|
||||
|
||||
|
||||
class TestChatRequest:
|
||||
def test_defaults_and_frozen(self):
|
||||
req = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
||||
assert req.session_id is None
|
||||
assert req.parent_call_id is None
|
||||
assert req.cache_salt is None
|
||||
assert req.cache_namespace is None
|
||||
assert req.structured is None
|
||||
assert req.stream is True
|
||||
assert req.overlay == {}
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
req.stream = False
|
||||
|
||||
def test_replace_derivation(self):
|
||||
"""中间件用 dataclasses.replace 派生新请求(设计 §4.1 方案 A1)。"""
|
||||
req = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
||||
derived = dataclasses.replace(req, overlay={"response_format": {"type": "json_object"}})
|
||||
assert derived.overlay and req.overlay == {}
|
||||
|
||||
|
||||
class TestSourceConfig:
|
||||
def test_minimal_valid(self):
|
||||
src = _make_source()
|
||||
assert src.max_concurrency == 0 and src.rpm == 0 and src.tpm == 0
|
||||
assert src.est_tokens == 0
|
||||
assert src.enable_thinking is None
|
||||
assert src.missing_done == "retry"
|
||||
assert src.trust_env is True
|
||||
|
||||
@pytest.mark.parametrize("field", ["name", "provider", "base_url", "api_key", "model"])
|
||||
def test_identity_fields_must_be_non_empty(self, field):
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(**{field: " "})
|
||||
|
||||
def test_timeout_must_be_positive(self):
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(timeout_s=0)
|
||||
|
||||
def test_tpm_requires_est_tokens(self):
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(tpm=10000, est_tokens=0)
|
||||
assert _make_source(tpm=10000, est_tokens=800).est_tokens == 800
|
||||
|
||||
def test_negative_gate_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(rpm=-1)
|
||||
|
||||
def test_watchdog_invariant_chain(self):
|
||||
"""CHS config.py:66-82: 0 < inter < ttft < timeout_s。"""
|
||||
ok = _make_source(ttft_timeout_s=30.0, inter_token_timeout_s=15.0)
|
||||
assert ok.ttft_timeout_s == 30.0
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(ttft_timeout_s=30.0, inter_token_timeout_s=40.0)
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(ttft_timeout_s=200.0, inter_token_timeout_s=15.0) # ttft >= timeout
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(ttft_timeout_s=30.0) # 只设其一
|
||||
|
||||
def test_missing_done_domain(self):
|
||||
assert _make_source(missing_done="salvage").missing_done == "salvage"
|
||||
with pytest.raises(ValueError):
|
||||
_make_source(missing_done="ignore")
|
||||
|
||||
|
||||
class TestResilienceConfigs:
|
||||
def test_retry_policy_validation(self):
|
||||
assert RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0)
|
||||
with pytest.raises(ValueError):
|
||||
RetryPolicy(max_attempts=0, backoff_base_s=2.0, backoff_max_s=30.0)
|
||||
with pytest.raises(ValueError):
|
||||
RetryPolicy(max_attempts=3, backoff_base_s=30.0, backoff_max_s=2.0)
|
||||
|
||||
def test_breaker_config_validation(self):
|
||||
assert BreakerConfig(fail_threshold=5, cooldown_s=60.0, probe_ttl_s=120.0)
|
||||
with pytest.raises(ValueError):
|
||||
BreakerConfig(fail_threshold=0, cooldown_s=60.0, probe_ttl_s=120.0)
|
||||
|
||||
def test_backpressure_and_global_limits(self):
|
||||
assert BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.5)
|
||||
with pytest.raises(ValueError):
|
||||
BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0)
|
||||
assert GlobalLimits(max_concurrency=8, rpm=60, tpm=100000)
|
||||
with pytest.raises(ValueError):
|
||||
GlobalLimits(max_concurrency=-1, rpm=0, tpm=0)
|
||||
|
||||
|
||||
class TestAuxTypes:
|
||||
def test_usage_and_stats(self):
|
||||
u = Usage(prompt_tokens=10, completion_tokens=20, usage_source="estimated")
|
||||
assert u.prompt_tokens == 10
|
||||
s = TransportResult(
|
||||
content="c", thinking="", prompt_tokens=1, completion_tokens=2,
|
||||
usage_source="measured", ttft_ms=12.5, max_inter_token_ms=30.0, raw={"id": "x"},
|
||||
)
|
||||
assert s.raw["id"] == "x"
|
||||
Reference in New Issue
Block a user