Files
PolyGateway/research-wiki/designs/2026-07-29-settings-invariant-guards-design.md
iomgaa b693d442f5 docs: record approval, verification evidence and a follow-up gap
Corrects the changelog claim that the old guard messages only named env keys:
they named fields too, it was the remediation advice that pointed at env keys.

Records the human approval of the design, the mutation-testing evidence from
the independent verifier, and the same-class gap it found: 14 checks (redis_url
presence, telemetry paths, enum validity) still live only in from_env, while
client.py asserts they were already validated. Deliberately out of scope here.
2026-07-30 00:36:59 -04:00

13 KiB
Raw Permalink Blame History

GatewaySettings 跨字段不变量守卫的生效范围

  • 日期: 2026-07-29;状态: 已批准并实施(2026-07-29 人类门通过;§8 结论见文末)
  • 范围拍板(用户 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_slease_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_SValueError 保留(同上,消息中不再含 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注意抛点:方案 A 之下非法实例根本无法存在,异常发生在实参求值(构造 settings)那一刻,不在工厂内部——这正是构造期把关换来的性质,测试 docstring 须写明,以免后人误读为工厂自带校验
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. 人类拍板结论(2026-07-29)

问题 结论
主决策 接受方案 A,构造期强制,承诺收紧
范围 全量:probe_ttl_s 与空 sources 一并纳入
消息文案 去掉 env 键名,只点字段名(§2.3)
版本 1.0.1(patch);初稿建议的 minor 被否,理由与代偿见 §4
PR#1 处置 重写合并后关闭并说明,诊断归功于提交者

9. 实施与验证留痕

实施于 fix/settings-invariant-guards(5 commits)。TDD 证据:新测试类先 6 failed / 3 passed(3 条为边界护栏,本就应过),实现后全绿。

独立 verifier(全新上下文)核验结论 可以合并,无阻塞,其中两项证据值得留档:

  • 变异测试 12/12 全杀:逐个破坏实现(删各 _validate_* 调用、>>=<<=、删空源检查、_PROBE_GRACE_S 归零)均有测试失败,无一存活。边界侧用例(恰好相等必过)对每条守卫都真实有效,差一错误可捕获。
  • 无热路径回归:库内没有任何地方构造或 replace GatewaySettings(src/ 中 4 处 dataclasses.replace 全在 middleware/structured.py,作用于 ChatRequest/LLMResponse)。单次构造实测 1.45 µs,装配期一次性成本。pickle/deepcopy 不触发 __post_init__,只有 replace 触发——序列化往返既无额外开销也不构成二次守卫点。

9.1 verifier 发现的同族遗漏(范围外,另起任务)

GatewaySettings 仍有 14 条校验只挂在 from_env,直接构造/replace 全部放行,与本设计所修的是同一个 bug 类:limiter/breaker/cache_backend=redisredis_url=Nonetelemetry_backend=sqlite/postgres 但 path/dsn 为 None、selector/quota_full/各 backend 的枚举合法性、structured_max_retries 负值、scope 空串等。

严重性高于本次所修的三条,因为 client.py:262/282/302/312/316 有 5 处 assert ... # 内部不变量: config 已校验 明文依赖这个前提,而该前提在 from_settings 路上为假:断言开启时抛裸 AssertionError(不点字段不说原因),python -O 下断言消失、错误退化为 redis 库抛出的天书。后者同时违反 CLAUDE.md §4.3"禁止 assert 承担生产校验"。

有意不纳入本次交付(避免任务外扩张),另起任务处理。