Guards for the three cross-field invariants (source timeout vs lease TTL, stall window vs max TTFT, probe TTL vs slowest timeout) only ran inside from_env, so the from_settings path could build a GatewaySettings that violates the class's own documented invariants. Design moves all of them plus a non-empty sources check into __post_init__ as _validate_* methods, matching the existing frozen dataclasses in types.py. Covers two defects left by PR#1: probe_ttl_s was never moved, and an empty sources tuple leaked a bare 'max() arg is an empty sequence'.
11 KiB
GatewaySettings 跨字段不变量守卫的生效范围
- 日期: 2026-07-29;状态: 待人类审批(改变库对下游的构造承诺,强制人类门)
- 范围拍板(用户 2026-07-29): 功能对齐社区 PR#1,但按本库规范重写;顺带销掉 PR#1 遗留的两个缺陷
- 上游依据: ARCHITECTURE §7.3 契约补强 G6(装配期守卫,"违反直接报错拒绝装配")、§9 配置聚合、CLAUDE.md §4.5(装配只有两条路)、
types.py同族 frozen dataclass 的既有校验笔迹
1. 缺陷取证(全部本地实测,worktree @ f76a89b 与 main 对照)
GatewaySettings 有三条跨字段不变量——单个字段合法、组合起来才非法,因此 types.py 各子配置的 __post_init__ 管不到,只能在聚合层管:
| 不变量 | 现居位置 | 违反后的运行时后果 |
|---|---|---|
源 timeout_s ≤ lease_ttl_s |
_guard_lease,仅 from_env 调用 |
租约先于请求过期,名额被放给他人 → 实际并发超配额,击穿网关 |
backpressure.stall_window_s ≥ 最大源 ttft_timeout_s |
_guard_stall,仅 from_env 调用 |
正常慢首包被误判卡死掐断 |
breaker.probe_ttl_s ≥ 最慢源 timeout_s + 5 |
_load_breaker 内联,仅 from_env 路径 |
半开探针在途即被接管(M2 设计 §3 原文) |
三条守卫都只挂在 from_env 上,而 CLAUDE.md §4.5 规定装配有两条官方路。走 from_settings() 能装出违反上述任一条的配置且不报错——类可以合法地存在于它自己 docstring 声称不可能的状态。
实测(在 PR#1 分支上,即已修前两条之后):
| 构造方式 | 结果 |
|---|---|
replace(base, breaker=replace(base.breaker, probe_ttl_s=1.0))(最慢 timeout 120s) |
未拦截,装配成功 |
replace(base, sources=()) |
ValueError: max() arg is an empty sequence —— 内置异常泄漏,既不点字段也不说原因 |
第一条说明 PR#1 的搬迁不完整:它的全部论证同等适用于 probe_ttl_s,却只搬了两条。第二条是 PR#1 新引入的失败模式——max() 此前只在 _load_sources 保证非空之后才执行,守卫上移到构造期后失去了这个前提。
另有一条隐性不变量此前从未表达:sources 不得为空。from_env 路径由 _load_sources 显式拦截,直接构造路径无人把关,零源的 client 装出来后选源必然失败。
2. 备选方案对比
| 方案 | 做法 | 权衡 |
|---|---|---|
A. __post_init__ 集中校验(推荐) |
三条跨字段守卫 + 空源检查全部收进 GatewaySettings.__post_init__,拆为 _validate_sources/_validate_lease/_validate_stall/_validate_probe 私有方法 |
与 types.py 同族五个 frozen dataclass 的既有笔迹完全一致;一处覆盖全部构造路径(六个工厂 + 直接构造 + dataclasses.replace);代价是收紧了构造承诺(见 §4) |
B. 各工厂入口显式调用 settings.validate() |
三个 client × 两个工厂,六处各加一行 | 不改构造承诺,零 breaking;但六处要永久保持同步,新增第四个 client 时必漏——正是"每个调用方各维护一份副本"的毛病挪进库里。且 dataclasses.replace 仍能绕过。否决 |
C. 公共 settings.validate(),由调用方自愿调 |
提供校验入口,不强制 | 把类不变量降级成"建议";违反 P5 防御性(外部输入校验后使用)与 ARCHITECTURE §7.3"违反直接报错拒绝装配"。否决 |
方案 A 与 SourceConfig.__post_init__ 同构。选它的核心理由不是"少写五行",是不变量的归属:这三条约束是 GatewaySettings 这个类的定义的一部分,不是 from_env 这个函数的输入检查。放在函数里,类就失去了自我描述能力。
2.1 子决策:守卫的代码形态
config.py 现有 _guard_lease(settings) / _guard_stall(settings) 两个模块级函数,把自身实例传回给模块级函数是绕路。types.py 的既有做法是私有方法(SourceConfig 拆三个 _validate_*)。改为私有方法,与同族一致;模块级 _guard_* 一并删除(无其他调用点)。
2.2 子决策:probe_ttl_s 的派生逻辑留在哪
_load_breaker 对该字段做了两件事:未配置时派生(max(2*slowest, cooldown_s, probe_floor),派生规则本身保证守卫恒成立)、显式配置时校验。派生需要读 env,必须留在 _load_breaker;校验上移到 __post_init__ 后,_load_breaker 内联的那份校验删除(避免同一约束两处维护)。派生分支上移后仍恒过,无行为变化。
2.3 子决策:错误消息里是否列 env 键名
不列。 三条理由:(1) types.py 全部校验消息只点字段名,是既有笔迹;(2) 守卫现在服务两类调用方,env 键对手工拼 settings 的那类是不可执行的建议;(3) 键名的单一事实源是 .env.example 与 wiki 参考-配置键,消息里复制一份即双处维护。消息格式沿用既有句式:字段名(值)须 …;调大 X 或调小 Y。
与 PR#1 的差异:PR#1 选择"点字段名 + 括号附 env 键",单行超 100 字符且把
{SCOPE}__{PROVIDER}__{N}__TIMEOUT_S模板塞进运行时消息。本方案只留字段名。
3. 行为审计(逐条标注)
不是从 reference/ 迁移,是既有模块的行为收紧,故审计对象为现有 from_env 路径的全部可观测行为:
| 现有行为 | 处置 |
|---|---|
from_env 装配非法 lease/stall 组合 → ValueError |
保留(改由 __post_init__ 抛,时机提前到 cls(...) 那一行,对调用方不可见) |
from_env 配了过小 PROBE_TTL_S → ValueError |
保留(同上,消息中不再含 env 键名 —— 有意变更,§2.3) |
from_env 未配 PROBE_TTL_S → 派生值 |
保留,派生规则一字不改 |
from_env 未配任何源 → ValueError: scope X 未配置任何源 |
保留,_load_sources 的检查不动(它能给出键名模板,信息量高于构造期检查) |
直接构造/replace 出非法组合 → 静默成功 |
有意替换为构造期 ValueError(本设计的目的) |
直接构造空 sources → 静默成功 |
有意替换为构造期 ValueError,消息点明"至少一个源" |
三条守卫的异常类型 ValueError |
保留。装配期错误不入 errors.py 四分类(四分类描述的是一次调用的失败),与 _load_sources/types.py 既有装配错误一致 |
GatewaySettings 字段名与类型 |
不动。迁移兼容约束(CLAUDE.md §4.3 例外条款)只增不删不改名,本次零字段变更 |
有意放弃:不提供 strict=False 之类的逃生开关。装出必然故障的配置没有正当用例。
4. 对下游的承诺变化(人类门要审的就是这条)
| 调用方式 | 影响 |
|---|---|
GatewayClient.from_env() / OcrClient.from_env() / EmbeddingClient.from_env() |
零影响,该路径本就跑这些守卫 |
*.from_settings(settings),settings 来自 from_env |
零影响 |
手工构造 GatewaySettings(...) 或 dataclasses.replace(...),组合合法 |
零影响 |
手工构造/replace,组合非法 |
行为变更:构造期抛 ValueError,不再留到运行时表现为超配额/误判卡死/探针被接管 |
已知受影响的下游:CHSAnalyzer 重建中的 YAML → 直接构造 → from_settings() 路径(PR#1 提交者正是在此撞上的)。该路径若配置合法则不受影响,若非法则从"静默故障"变为"启动即报错"——方向是收益。
版本:1.0.1(patch,用户 2026-07-29 拍板)。设计初稿曾建议 minor(构造期新抛 ValueError 是可观测的收紧),用户判定受影响面仅限"手工拼出非法配置"这一本就故障的路径,按修复发 patch。CHANGELOG 必须把行为收紧单列小节,不能只混在"修复"里——patch 号不会给下游预警,changelog 是唯一的告知渠道。
发版时按 docs-convention §2 末行过发布清单;wiki 参考-配置键 页现有表述("须 ≤ PGW_LEASE_TTL_S""须 ≥ 最大源 TTFT")与新行为一致,无需改动内容。
5. 非功能维度
| 维度 | 回答 |
|---|---|
| 并发与取消 | 不适用但需写明:__post_init__ 是同步纯计算(只读自身字段做比较),无 I/O、无 await、无锁,不存在取消穿透点。不引入任何全局状态,纯 asyncio 中立铁律不受影响 |
| 降级方向 | 装配期校验属准入侧,按库铁律"报错而非放行"。无后端依赖,无降级分支 |
| 幂等与重复 | __post_init__ 不修改任何字段(frozen 也不允许),重复构造同一配置得同一结果;校验本身无副作用 |
| 持久化与原子性 | 不适用,配置对象不落盘 |
| 性能 | 每次构造增加三次 max() 遍历 sources(典型 1-4 个源)。GatewaySettings 只在装配期构造,不在请求路径上,可忽略 |
6. 测试策略
tests/unit/test_config.py 新增一个测试类,覆盖矩阵为 4 条不变量 × 2 条构造路径:
| 用例 | 断言 |
|---|---|
三条守卫各自:dataclasses.replace 构造出违反组合 |
抛 ValueError,消息含对应字段名 |
三条守卫各自:边界值恰好相等(timeout_s == lease_ttl_s 等) |
构造成功——守卫收紧的是错的那些,不是所有直接构造 |
sources=() |
抛 ValueError,消息点明"至少一个源",且不是 max() arg is an empty sequence |
GatewayClient.from_settings(非法 settings) |
抛 ValueError(真实动机路径,PR#1 只做了手工复现未落成测试) |
OcrSettings / EmbeddingSettings 直接构造包着非法 gateway |
抛 ValueError(证明三条 client 线一并覆盖) |
| 既有 447 passed / 34 skipped | 全绿,零回归 |
TDD 顺序:先写测试跑出预期失败(预计 3 条守卫 + 空源 + from_settings 端到端 共失败 6 条以上),再实现,再全绿。测试不新增 mock,全部用既有 _env() helper 构造真实 settings 再派生。
7. 与 PR#1 的关系
功能对齐,不是推翻。PR#1 的问题诊断完全正确,本设计沿用其核心结论(守卫属于类不变量,应在构造期生效),差异集中在:
| 维度 | PR#1 | 本设计 |
|---|---|---|
| 覆盖的不变量 | 2 条 | 4 条(补 probe_ttl_s、空 sources) |
| 代码形态 | 保留模块级 _guard_*(settings) |
改为 _validate_* 私有方法,同 SourceConfig |
| docstring | 引用 CLAUDE.md §4.5(下游读者看不到该文件)、带论证口吻 |
只引 ARCHITECTURE §7.3 与自身概念,解释"为什么"不复述辩论 |
| 错误消息 | 字段名 + 附 env 键模板 | 只点字段名(§2.3) |
| 测试 | 4 条,全走 replace,其中 1 条同义反复 |
覆盖 4 不变量 × 2 路径 + 边界值 + 三条 client 线 |
| 导入位置 | 两处函数内 import dataclasses |
文件顶部 |
合并后应关闭 PR#1 并在其中说明:诊断被采纳,实现按库内规范重写并扩展了覆盖范围。
8. 待人类拍板
- 主决策:接受方案 A(构造期强制,承诺收紧)?
- 范围:
probe_ttl_s与空 sources 一并纳入(推荐),还是严格只对齐 PR#1 的两条? - 消息文案:去掉 env 键名(推荐,§2.3),还是保留 PR#1 的附注形式?
- PR#1 处置:重写合并后关闭并说明,还是先请提交者按 review 修改?