fix: enforce cross-field settings invariants on every construction path
The lease, stall and probe-TTL guards only ran inside GatewaySettings.from_env, so from_settings() and direct construction could produce settings that violate the class's own invariants: the permit lease could expire mid-request (silently exceeding the concurrency quota), a normal slow first token could be killed as a stall, and a half-open probe could be taken over while still in flight. Guards move into __post_init__ as _validate_* methods, matching every frozen dataclass in types.py, so all six factories plus dataclasses.replace are covered by one check. Adds a non-empty sources invariant that previously only from_env enforced. Messages now name fields instead of env keys, since callers who build settings by hand never set those keys.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from polygateway.config import GatewaySettings
|
||||
from polygateway.client import GatewayClient
|
||||
from polygateway.config import GatewaySettings, OcrSettings
|
||||
|
||||
_BASE_ENV = {
|
||||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||||
@@ -319,3 +322,94 @@ class TestOcrSettings:
|
||||
env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"}
|
||||
with pytest.raises(ValueError):
|
||||
OcrSettings.from_env("OCR", env=env)
|
||||
|
||||
|
||||
class TestCrossFieldInvariants:
|
||||
"""四条跨字段不变量必须在**任何**构造路径上生效(设计 2026-07-29)。
|
||||
|
||||
这些约束单看一个字段都合法,组合起来才非法,因此 types.py 各子配置的
|
||||
__post_init__ 看不见——只能由聚合层 GatewaySettings 把关。守卫若只挂在
|
||||
from_env 上,from_settings 这条同等官方的装配路(CLAUDE.md §4.5)就能
|
||||
装出违反不变量的配置,类会存在于自己 docstring 声称不可能的状态。
|
||||
|
||||
每条不变量测两侧: 越界必拒、边界值(恰好相等)必过——收紧的是错的组合,
|
||||
不是所有直接构造。
|
||||
"""
|
||||
|
||||
def _base(self, **overrides) -> GatewaySettings:
|
||||
return GatewaySettings.from_env("LLM", env=_env(**overrides))
|
||||
|
||||
def _with_watchdog(self) -> GatewaySettings:
|
||||
"""带看门狗的基准: TTFT/inter-token 成对配置才满足 SourceConfig 不变式。"""
|
||||
return self._base(
|
||||
**{
|
||||
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||||
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||||
"LLM__BACKPRESSURE__STALL_WINDOW_S": "300",
|
||||
}
|
||||
)
|
||||
|
||||
# —— 源超时 ≤ permit 租约 TTL(ARCH §7.3: 防租约先于请求过期,并发悄悄超配额)——
|
||||
|
||||
def test_lease_rejects_timeout_above_ttl_on_direct_construction(self):
|
||||
base = self._base() # 源 timeout_s=120
|
||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||
dataclasses.replace(base, lease_ttl_s=1.0)
|
||||
|
||||
def test_lease_accepts_timeout_equal_to_ttl(self):
|
||||
base = self._base()
|
||||
assert dataclasses.replace(base, lease_ttl_s=120.0).lease_ttl_s == 120.0
|
||||
|
||||
# —— stall 窗口 ≥ 最大源 TTFT(ARCH §7.3: 防正常慢首包被误判卡死掐断)——
|
||||
|
||||
def test_stall_rejects_window_below_max_ttft_on_direct_construction(self):
|
||||
base = self._with_watchdog() # 源 ttft_timeout_s=30
|
||||
narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0)
|
||||
with pytest.raises(ValueError, match="stall_window_s"):
|
||||
dataclasses.replace(base, backpressure=narrowed)
|
||||
|
||||
def test_stall_accepts_window_equal_to_max_ttft(self):
|
||||
base = self._with_watchdog()
|
||||
exact = dataclasses.replace(base.backpressure, stall_window_s=30.0)
|
||||
assert dataclasses.replace(base, backpressure=exact).backpressure.stall_window_s == 30.0
|
||||
|
||||
# —— 探针租约 ≥ 最慢源超时 + 5(M2 设计 §3: 防半开探针在途即被接管)——
|
||||
|
||||
def test_probe_rejects_ttl_below_floor_on_direct_construction(self):
|
||||
base = self._base() # 最慢 timeout_s=120,故下限 125
|
||||
shortened = dataclasses.replace(base.breaker, probe_ttl_s=100.0)
|
||||
with pytest.raises(ValueError, match="probe_ttl_s"):
|
||||
dataclasses.replace(base, breaker=shortened)
|
||||
|
||||
def test_probe_accepts_ttl_at_floor(self):
|
||||
base = self._base()
|
||||
at_floor = dataclasses.replace(base.breaker, probe_ttl_s=125.0)
|
||||
assert dataclasses.replace(base, breaker=at_floor).breaker.probe_ttl_s == 125.0
|
||||
|
||||
# —— sources 非空 ——
|
||||
|
||||
def test_empty_sources_rejected_with_actionable_message(self):
|
||||
"""零源装出来的 client 选源必然失败;消息须点明原因,不能泄漏 max() 的内置异常。"""
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError) as exc:
|
||||
dataclasses.replace(base, sources=())
|
||||
assert "至少一个源" in str(exc.value)
|
||||
assert "empty sequence" not in str(exc.value)
|
||||
|
||||
# —— 装配路径覆盖 ——
|
||||
|
||||
def test_factory_cannot_receive_invalid_settings(self):
|
||||
"""from_settings 这条路吃不到非法配置。
|
||||
|
||||
异常实际抛在实参求值(构造 settings)那一刻,而不是工厂内部——这正是
|
||||
把守卫放构造期换来的性质: 非法实例根本不存在,无需每个工厂各自设防。
|
||||
"""
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
||||
|
||||
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
||||
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
|
||||
|
||||
Reference in New Issue
Block a user