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
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 未发布
### 修复
- **装配守卫在任何构造路径上都生效,不再只在 `from_env` 上。** `_guard_lease`
(源超时须 ≤ permit 租约 TTL)与 `_guard_stall`(stall 窗口须 ≥ 最大 TTFT)原先只写在
`GatewaySettings.from_env` 里,而 CLAUDE.md §4.5 规定装配有两条官方路径
——走 `from_settings()` 的调用方能装出违反类不变量的配置,且不报任何错。
守卫已挪进 `GatewaySettings.__post_init__`,与 `SourceConfig` 的做法一致。
三个 client(Gateway/Ocr/Embedding)各有两个工厂,共六个入口,一并覆盖。
**行为收紧,可能影响下游**: 直接构造 `GatewaySettings`(或对它做
`dataclasses.replace`)时若组合非法,现在会在构造期抛 `ValueError`,
而不是留到运行时表现为租约先于请求过期、或正常慢首包被误判为卡死。
`from_env` 装配的调用方不受影响——那条路本来就跑这两个守卫。
- 两个守卫的报错文案改为点字段名(`lease_ttl_s``backpressure.stall_window_s`),
环境变量键降为补充信息。原文案只点环境变量键,而不走 env 的调用方从没设过它们。
## 1.0.0(2026-07-22)
首个正式版。统一 LLM/VLM/OCR/Embedding 调度与中转库,治理单位为一次模型调用;经 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目全量迁移验收(ARCHITECTURE §11)。
+31 -9
View File
@@ -88,7 +88,15 @@ def _require(env: Mapping[str, str], *keys: str) -> tuple[str, str]:
@dataclass(frozen=True)
class GatewaySettings:
"""一个 scope 的完整装配配置;构造经 from_env 聚合并通过全部守卫。"""
"""一个 scope 的完整装配配置;**任何**构造路径都通过全部守卫。
守卫在 `__post_init__` 里跑,而不是在 `from_env` 里。原因是装配有两条官方路径
(CLAUDE.md §4.5: `from_env()` 与 `from_settings()`),守卫只挂在其中一条上的话,
另一条就能装出违反本类不变量的配置 —— 类会存在于它自己声称不可能的状态。
放构造期还有一个好处: 三个 client(Gateway/Ocr/Embedding)各有两个工厂,
共六个入口,挂在这里是一处,挂在工厂里是六处要保持同步。
与 `SourceConfig.__post_init__` 的做法一致。
"""
scope: str
sources: tuple[SourceConfig, ...]
@@ -111,6 +119,10 @@ class GatewaySettings:
structured_max_retries: int
lease_ttl_s: float
def __post_init__(self) -> None:
_guard_lease(self)
_guard_stall(self)
@classmethod
def from_env(
cls,
@@ -140,8 +152,7 @@ class GatewaySettings:
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
**_load_pgw(env),
)
_guard_lease(settings)
_guard_stall(settings)
# 守卫已在 __post_init__ 里跑过,这里不再重复调用。
return settings
@@ -340,22 +351,33 @@ def _load_structured_retries(env: Mapping[str, str]) -> int:
def _guard_lease(settings: GatewaySettings) -> None:
"""装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。"""
"""装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。
文案点字段名而不是只点环境变量键: 守卫在每次构造时都跑,而走 `from_settings`
的调用方从没设过那些键,让他去"调大 PGW_LEASE_TTL_S"是句没法执行的建议。
环境变量键作为补充信息附在后面,给走 `from_env` 的人用。
"""
slowest = max(s.timeout_s for s in settings.sources)
if slowest > settings.lease_ttl_s:
raise ValueError(
f"源最大 timeout_s({slowest})超过 permit 租约 TTL({settings.lease_ttl_s});"
f"调大 PGW_LEASE_TTL_S 或调小超时"
f"源最大 timeout_s({slowest})超过 permit 租约 TTL(lease_ttl_s="
f"{settings.lease_ttl_s});调大 lease_ttl_s 或调小源的 timeout_s"
f"(走 from_env 时对应的键是 PGW_LEASE_TTL_S 与 {{SCOPE}}__{{PROVIDER}}__{{N}}__TIMEOUT_S)"
)
def _guard_stall(settings: GatewaySettings) -> None:
"""装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。"""
"""装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。
文案点字段名的理由同 `_guard_lease`。
"""
ttfts = [s.ttft_timeout_s for s in settings.sources if s.ttft_timeout_s is not None]
if ttfts and settings.backpressure.stall_window_s < max(ttfts):
raise ValueError(
f"stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 "
f"ttft_timeout_s({max(ttfts)});调大 BACKPRESSURE__STALL_WINDOW_S 或调小 TTFT"
f"backpressure.stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 "
f"ttft_timeout_s({max(ttfts)});调大 stall_window_s 或调小 ttft_timeout_s"
f"(走 from_env 时对应的键是 {{SCOPE}}__BACKPRESSURE__STALL_WINDOW_S 与 "
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__TTFT_TIMEOUT_S)"
)
+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)