fix(config): 装配守卫挪进 __post_init__,任何构造路径都生效 #1
Reference in New Issue
Block a user
Delete Branch "fix/guards-on-every-settings-construction"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
问题
GatewaySettings的 docstring 写着「构造经 from_env 聚合并通过全部守卫」,但_guard_lease和_guard_stall只写在from_env里(config.py:143-144)。而 CLAUDE.md §4.5 规定装配有两条官方路径——走from_settings()能装出一个违反类不变量的 settings,且不报任何错。这个类可以合法地存在于它自己声称不可能的状态。漏的不是一个入口,是六个:三个 client(Gateway / Ocr / Embedding)各有两个工厂。
OcrSettings和EmbeddingSettings都只包一个GatewaySettings,它们的from_env转调GatewaySettings.from_env,所以三条线一模一样地漏。后果是两条真守卫失效:
改法
守卫挪进
GatewaySettings.__post_init__,与同族的SourceConfig一致。放构造期而不是在每个工厂里各加一行:六个入口,挂构造期是一处,挂工厂是六处要保持同步——那正是「每个调用方各维护一份副本」的毛病,只是挪进了库里面。
顺带改了两个守卫的报错文案,点字段名(
lease_ttl_s、backpressure.stall_window_s)而不是只点环境变量键,键降为补充信息。守卫现在每次构造都跑,而走from_settings的调用方从没设过那些键,让他「调大PGW_LEASE_TTL_S」是句没法执行的建议。行为收紧(下游注意)
直接构造
GatewaySettings或对它做dataclasses.replace,若组合非法,现在构造期就抛ValueError,而不是留到运行时。经from_env装配的调用方完全不受影响——那条路本来就跑这两个守卫。严格说这是 1.0.0 上的行为变更,建议走 minor 而不是 patch。
验证
TestGuardsRunOnEveryConstruction四条,先失败 3 条、改完全过。GatewaySettings,全部经from_env,所以这次收紧对现有套件是行为不变的。ruff check、ruff format --check、lint-imports三门均过(Contracts: 1 kept, 0 broken)。GatewayClient.from_settings(非法 settings)装配成功,改后被拦住并给出可执行的错误消息。一点需要 owner 判断的
CLAUDE.md §3 Phase 1 有一条「涉及公共 API 变更必须先 brainstorming 加人类确认」。我按「这是 bug fix、恢复文档已声明的不变量」判断没有触发,但它确实改变了库对下游的构造承诺。如果认为该走那道流程,这个 PR 先别合,我把方案文档补上。
这个改动是怎么被发现的
下游 CHSAnalyzer 重建时把配置改成读 YAML 直接构造
GatewaySettings再交给from_settings(),读源码确认这条路能不能走的时候撞上的。🤖 Generated with Claude Code
不合并本 PR,但问题全部采纳,已在 v1.0.1 与 v1.0.2 修完
诊断是准确的:
GatewaySettings的 docstring 声称"构造经 from_env 聚合并通过全部守卫",而守卫只挂在from_env上,from_settings()与直接构造能装出违反类不变量的配置且不报错。"这个类可以合法地存在于它自己声称不可能的状态"这句概括也准确。改法(挂构造期一处,而非六个工厂各一行)同样是对的——挂工厂就是把"每个调用方各维护一份副本"的毛病挪进库里。不直接合并的理由是笔迹,不是逻辑。 PR 用模块级
_guard_*函数,而types.py里同族的校验一律是_validate_*私有方法;docstring 也引了 CLAUDE.md 的条款号,库内没有这种写法。这在应用里无所谓,在基础库里不行——这里的每一段代码都会成为后来人添新校验时模仿的样板,两套并行的写法会一直分叉下去。所以按库内既有规范重写了。实际做的比本 PR 大
v1.0.1 收了本 PR 覆盖的三条跨字段守卫(源
timeout_s≤lease_ttl_s、stall_window_s≥ 最大源 TTFT、probe_ttl_s≥ 最慢源timeout_s+ 5),外加sources非空。v1.0.2 是独立验证逼出来的第二轮:
from_env上还留着同一类的 15 条校验(六个字段的枚举合法域、条件必填、标量域)与 4 条规范化,from_settings与直接构造全部放行。其中危害最大的一条本 PR 没覆盖到——scope的小写规范化只在 env 路做:顺带清掉了
client.py五处assert ... # 内部不变量: config 已校验的假前提——那些注释在from_settings路上一直是假的,断言开启时抛不含字段信息的AssertionError,python -O下退化成 redis 库的连接串解析天书。有意保留一处两路不同:
_load_breaker的阈值派生max(配置值, 源级并发×2)仍只在 env 路做。派生 ≠ 校验,这条已在设计文档里备案,免得下一轮又被当成"遗漏"翻出来。逐条答复 PR 里的两个判断题
报错文案点字段名 —— 采纳了。原文案点的是环境变量键("调大
PGW_LEASE_TTL_S"),而不走 env 的调用方从没设过那些键,那是句没法执行的建议。现在点lease_ttl_s、backpressure.stall_window_s、breaker.probe_ttl_s,键名降为补充信息。"是否该走 brainstorming 加人类门" —— 走了。两轮各有设计文档并经独立审查与人类批准,存档在
research-wiki/designs/2026-07-29-settings-invariant-guards-design.md与research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md。你按"这是 bug fix、恢复文档已声明的不变量"判断没触发,这个判断本身站得住;但它确实改变了库对下游的构造承诺,所以还是补了流程。以后遇到同类边界情形,先提出来问是对的做法。版本号 —— 你建议 minor,最终定了 patch。理由是字段与环境键都没删没改名、端口签名未动、API 严格向后兼容,变的是非法配置的失败时机(运行时 → 构造期)。代偿是 CHANGELOG 单列"行为收紧(下游请读)"小节,把每一条行为变化和升级注意事项都写明,而不是靠版本号那一位数字传达。
现状
本 PR 分支基于的 main 已前进(
mergeable: false),且改动点已被上述两版覆盖,故关闭。分支fix/guards-on-every-settings-construction留着,想留档就留,要清也可以。发现路径值得记一笔:你是在下游 CHSAnalyzer 改成读 YAML 直接构造
GatewaySettings时撞上的——这正说明from_settings()是条真实在走的路,不是理论上的第二入口。感谢。Pull request closed