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
+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)"
)