fix(config): 装配守卫挪进 __post_init__,任何构造路径都生效

_guard_lease 与 _guard_stall 原先只写在 GatewaySettings.from_env 里,
而 CLAUDE.md §4.5 规定装配有两条官方路径。结果是走 from_settings() 能装出
一个违反类不变量的 settings —— 这个类的 docstring 声称「构造经 from_env 聚合
并通过全部守卫」,但它可以合法地存在于自己声称不可能的状态。

守卫挪进 __post_init__,与同族的 SourceConfig 一致。放构造期而不是在每个工厂里
各加一行:三个 client(Gateway/Ocr/Embedding)各有两个工厂,共六个入口,
挂构造期是一处,挂工厂是六处要保持同步——那正是「每个调用方各维护一份副本」
的毛病,只是挪进了库里。OcrSettings 与 EmbeddingSettings 都包着一个
GatewaySettings,因此一并覆盖。

两个守卫的报错文案改为点字段名,环境变量键降为补充信息。守卫现在每次构造都跑,
而走 from_settings 的调用方从没设过那些键,让他「调大 PGW_LEASE_TTL_S」
是句没法执行的建议。

行为收紧:直接构造或 dataclasses.replace 出非法组合,现在构造期就抛 ValueError,
而不是留到运行时表现为租约先于请求过期、或正常慢首包被误判为卡死。
经 from_env 装配的调用方不受影响——那条路本来就跑这两个守卫。

测试:新增 TestGuardsRunOnEveryConstruction 四条(先失败 3 条后全过)。
既有 447 passed / 34 skipped 全部保持,无回归;ruff check、ruff format --check、
lint-imports 三门均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:32:03 -04:00
parent f17044dead
commit f76a89b1a1
3 changed files with 107 additions and 9 deletions
+57
View File
@@ -319,3 +319,60 @@ 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 TestGuardsRunOnEveryConstruction:
"""守卫必须在**任何**构造路径上生效,不只是 from_env。
背景: `GatewaySettings` 的 docstring 声称"构造经 from_env 聚合并通过全部守卫",
而 CLAUDE.md §4.5 明确装配有两条路(`from_env()`/`from_settings()`)。
守卫原先只写在 `from_env` 里,于是 `from_settings` 这条官方路径能装出
一个违反类不变量的 settings —— 类可以合法地存在于它自己声称不可能的状态。
"""
@staticmethod
def _replace(settings, **changes):
"""按既有 settings 派生一个改了几项的新 settings(走构造函数,不走 from_env)。"""
import dataclasses
return dataclasses.replace(settings, **changes)
def test_lease_guard_runs_on_direct_construction(self):
"""租约守卫: 源 timeout_s 超过 lease_ttl_s 时,直接构造也必须报错。"""
base = GatewaySettings.from_env("LLM", env=_env())
with pytest.raises(ValueError, match="租约|lease"):
self._replace(base, lease_ttl_s=1.0)
def test_stall_guard_runs_on_direct_construction(self):
"""卡死窗口守卫: stall_window_s 小于最大 ttft 时,直接构造也必须报错。"""
import dataclasses
base = GatewaySettings.from_env(
"LLM",
env=_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__BACKPRESSURE__STALL_WINDOW_S": "60",
}
),
)
narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0)
with pytest.raises(ValueError, match="stall"):
self._replace(base, backpressure=narrowed)
def test_valid_settings_still_constructible(self):
"""合法组合不受影响 —— 守卫收紧的是错的那些,不是所有直接构造。"""
base = GatewaySettings.from_env("LLM", env=_env())
assert self._replace(base, lease_ttl_s=base.lease_ttl_s).lease_ttl_s > 0
def test_guard_message_names_fields_not_only_env_keys(self):
"""报错要点得出字段名。
守卫一旦在每次构造时都跑,一个在代码里拼 settings 的调用方
(不走 env)会收到这条消息;只点环境变量名会让他去改几个他从没设过的键。
"""
base = GatewaySettings.from_env("LLM", env=_env())
with pytest.raises(ValueError) as exc:
self._replace(base, lease_ttl_s=1.0)
assert "lease_ttl_s" in str(exc.value)