fix(config): 装配守卫挪进 __post_init__,任何构造路径都生效 #1

Closed
iomgaa wants to merge 1 commits from fix/guards-on-every-settings-construction into main
Owner

问题

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)各有两个工厂。OcrSettingsEmbeddingSettings 都只包一个 GatewaySettings,它们的 from_env 转调 GatewaySettings.from_env,所以三条线一模一样地漏。

后果是两条真守卫失效:

  • 源超时超过 permit 租约 TTL —— 租约会在请求还在跑的时候过期,名额被放给别人,实际并发悄悄超出配额。
  • stall 窗口小于最大 TTFT —— 一次正常的、只是首包慢的调用会被误判成卡死掐掉。

改法

守卫挪进 GatewaySettings.__post_init__,与同族的 SourceConfig 一致。

放构造期而不是在每个工厂里各加一行:六个入口,挂构造期是一处,挂工厂是六处要保持同步——那正是「每个调用方各维护一份副本」的毛病,只是挪进了库里面。

顺带改了两个守卫的报错文案,点字段名(lease_ttl_sbackpressure.stall_window_s)而不是只点环境变量键,键降为补充信息。守卫现在每次构造都跑,而走 from_settings 的调用方从没设过那些键,让他「调大 PGW_LEASE_TTL_S」是句没法执行的建议。

行为收紧(下游注意)

直接构造 GatewaySettings 或对它做 dataclasses.replace,若组合非法,现在构造期就抛 ValueError,而不是留到运行时。from_env 装配的调用方完全不受影响——那条路本来就跑这两个守卫。

严格说这是 1.0.0 上的行为变更,建议走 minor 而不是 patch。

验证

  • 新增 TestGuardsRunOnEveryConstruction 四条,先失败 3 条、改完全过。
  • 既有 447 passed / 34 skipped 全部保持,改后 451,零回归。仓库里没有任何测试直接构造 GatewaySettings,全部经 from_env,所以这次收紧对现有套件是行为不变的。
  • ruff checkruff format --checklint-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

## 问题 `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`,所以三条线一模一样地漏。 后果是两条真守卫失效: - **源超时超过 permit 租约 TTL** —— 租约会在请求还在跑的时候过期,名额被放给别人,实际并发悄悄超出配额。 - **stall 窗口小于最大 TTFT** —— 一次正常的、只是首包慢的调用会被误判成卡死掐掉。 ## 改法 守卫挪进 `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 条**、改完全过。 - 既有 **447 passed / 34 skipped 全部保持**,改后 451,零回归。仓库里没有任何测试直接构造 `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](https://claude.com/claude-code)
iomgaa added 1 commit 2026-07-30 11:26:42 +08:00
_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>
Author
Owner

不合并本 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_slease_ttl_sstall_window_s ≥ 最大源 TTFT、probe_ttl_s ≥ 最慢源 timeout_s + 5),外加 sources 非空。

v1.0.2 是独立验证逼出来的第二轮:from_env 上还留着同一类的 15 条校验(六个字段的枚举合法域、条件必填、标量域)与 4 条规范化,from_settings 与直接构造全部放行。其中危害最大的一条本 PR 没覆盖到——scope 的小写规范化只在 env 路做:

scope 直接进 Redis key(pgw:limit:{scope}:…pgw:gate:{scope}:…)。一个进程走 from_env("LLM") 拿到 llm、另一个直接构造传 "LLM",同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间,各记各的配额与熔断状态,分布式治理静默失效且不报错。

顺带清掉了 client.py 五处 assert ... # 内部不变量: config 已校验 的假前提——那些注释在 from_settings 路上一直是假的,断言开启时抛不含字段信息的 AssertionError,python -O 下退化成 redis 库的连接串解析天书。

有意保留一处两路不同:_load_breaker 的阈值派生 max(配置值, 源级并发×2) 仍只在 env 路做。派生 ≠ 校验,这条已在设计文档里备案,免得下一轮又被当成"遗漏"翻出来。

逐条答复 PR 里的两个判断题

报错文案点字段名 —— 采纳了。原文案点的是环境变量键("调大 PGW_LEASE_TTL_S"),而不走 env 的调用方从没设过那些键,那是句没法执行的建议。现在点 lease_ttl_sbackpressure.stall_window_sbreaker.probe_ttl_s,键名降为补充信息。

"是否该走 brainstorming 加人类门" —— 走了。两轮各有设计文档并经独立审查与人类批准,存档在 research-wiki/designs/2026-07-29-settings-invariant-guards-design.mdresearch-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() 是条真实在走的路,不是理论上的第二入口。感谢。

## 不合并本 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 路做: > `scope` 直接进 Redis key(`pgw:limit:{scope}:…`、`pgw:gate:{scope}:…`)。一个进程走 `from_env("LLM")` 拿到 `llm`、另一个直接构造传 `"LLM"`,**同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间**,各记各的配额与熔断状态,分布式治理静默失效且不报错。 顺带清掉了 `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()` 是条真实在走的路,不是理论上的第二入口。感谢。
iomgaa closed this pull request 2026-07-31 16:12:03 +08:00

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#1