The verifier found four more env-only behaviours of the same class the branch
was already fixing. The worst is scope: it goes straight into the Redis keys
(pgw:limit:{scope}, pgw:gate:{scope}), so one process using from_env("LLM")
and another constructing scope="LLM" by hand split the rate limit and breaker
state across two namespaces, each tracking its own quota, with no error.
Blank redis_url and pricing_path now collapse to None as from_env has always
done, so they fall into the required-field checks instead of reaching the redis
client as an unparseable URL. EmbeddingSettings gains the __post_init__ it never
had, moving its batch_size and expected_dim checks off the from_env-only path.
Also adds the cache backend whitelist test that mutation testing showed missing.
11 KiB
GatewaySettings 装配校验补齐(第二轮)
- 日期: 2026-07-30;状态: 已批准并实施(2026-07-30 人类门通过;§9 结论、§10 实施留痕)
- 缘起: 2026-07-29-settings-invariant-guards-design.md §9.1 —— 独立 verifier 在第一轮交付后发现,
from_env上还留着一批同族校验;本设计是那一轮的续作,同一个 bug 类的剩余部分 - 上游依据: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路)
1. 待收拢的校验清单(逐条实测确认只在 from_env 生效)
A. 枚举合法域(6 条)
| 字段 | 合法域 | 现居 |
|---|---|---|
limiter_backend / breaker_backend |
{memory, redis} |
_load_pgw(经 _load_choice) |
cache_backend |
{redis, memory, none} |
_load_pgw 内联 |
telemetry_backend |
{sqlite, postgres, none} |
_load_pgw 内联 |
selector |
_SELECTORS |
from_env 调 _load_choice |
quota_full |
_QUOTA_FULL |
from_env 调 _load_choice |
直接构造传 selector="random" 或 cache_backend="rediss" 一律放行,后果是装配时落进 _build_* 的 else 分支或静默不建后端。
B. 条件必填(7 条,跨字段)
| 条件 | 要求 | 违反后果 |
|---|---|---|
limiter_backend/breaker_backend/cache_backend 取 redis |
redis_url 非空 |
见 §2,最严重 |
cache_backend != "none" |
cache_namespace 非空 |
缓存 key 失去租户隔离——踩"无缓存毒化"铁律 |
cache_backend != "none" |
cache_ttl_s > 0 |
from_env 明令禁止的"永不过期"从另一条路进来 |
telemetry_backend == "sqlite" |
telemetry_sqlite_path 非空 |
断言炸或写空路径 |
telemetry_backend == "postgres" |
telemetry_pg_dsn 非空 |
同上 |
C. 标量域(2 条)
structured_max_retries ≥ 0;scope 非空(空 scope 会污染遥测与缓存命名空间)。
2. 为什么这批比第一轮更严重:client.py 的断言前提为假
client.py 有 5 处断言明文声称这个前提已经成立:
assert settings.redis_url is not None # 内部不变量: config 已校验
位置:client.py:262/282/302(redis_url)、:312(pg_dsn)、:316(sqlite_path)。走 from_settings 时该注释是假的,verifier 实测:
| 运行方式 | 结果 |
|---|---|
| 断言开启 | AssertionError() —— 裸断言,不点字段、不说原因 |
python -O |
断言消失,退化为 redis 库的 ValueError: Redis URL must specify one of the following schemes... |
后者正是 CLAUDE.md §4.3 禁止的"assert 承担生产校验"。
但注意结论的方向:这 5 处 assert 本身不是要修的东西——它们要的前提是对的,错的是没人保证这个前提。§4 给出处置。
3. 方案
沿用第一轮已批准的方案 A,不重新论证:全部收进 GatewaySettings.__post_init__,新增三个私有方法与既有四个并列。
| 方法 | 覆盖 |
|---|---|
_validate_backends |
A 类 6 条枚举 + B 类 redis_url 三条件 |
_validate_cache |
cache_namespace 非空、cache_ttl_s > 0(仅 cache_backend != "none" 时) |
_validate_telemetry |
sqlite path / postgres dsn 条件必填 + §5 的 DSN 形态 |
标量两条(structured_max_retries、scope)并入 _validate_sources 改名后的 _validate_identity,与 SourceConfig._validate_identity 同名同职。
枚举合法域上提为模块级 frozenset 常量(_LIMITER_BACKENDS 等),_load_pgw 与 __post_init__ 共用一份,消除现有的内联字面量重复。
否决的替代:在 _build_limiter/_build_cache 等工厂函数里逐个补显式检查。理由同第一轮 §2 方案 B——校验散落在消费点,每加一个后端就多一处要同步,且 dataclasses.replace 仍绕过。
4. 5 处 assert 的处置:保留,不改
修好构造期校验后,settings.redis_url is not None 就真的成了内部不变量——CLAUDE.md §4.3 原文"assert 仅用于内部不变量"说的正是这种用法,同时它给类型检查器收窄了 str | None。此时删掉 assert 反而丢失类型信息,改成 raise 则是在防御一个已被构造期排除的情况(死代码)。
要改的是注释:# 内部不变量: config 已校验 应点明由谁保证,例如 # 内部不变量: GatewaySettings._validate_backends 已保证。前一轮的教训就是这类注释会随时间变成谎言。
5. Postgres DSN:校验而非规范化(本轮唯一的新决策)
_load_pg_dsn 对 from_env 读到的 DSN 做了规范化:剥掉 SQLAlchemy 风格的 +asyncpg 驱动后缀(asyncpg 不认)。直接构造那条路不会剥,postgresql+asyncpg://... 会原样送进 asyncpg 然后在首次写遥测时才炸。
| 选项 | 权衡 |
|---|---|
A. 构造期校验,含 +driver 即报错 |
显式,库不碰用户给的值;但两条装配路对同一输入接受度不同 |
| B. 构造期静默剥后缀 | 两条路完全对齐;但 frozen 类在构造期悄悄改字段,调用方不知情 |
C. 构造期剥后缀 + logger.warning(用户 2026-07-30 拍板) |
两条路行为对齐,同时不静默——调用方在日志里看得见库动了他的值,想根治就自己改 DSN |
选 C。实现要点:object.__setattr__ 改 frozen 字段(SourceConfig 无此先例,但 frozen 的约束是对外部不可变,构造期规范化是既有 dataclass 惯用法);warning 走 loguru(核心依赖,库内 ocr.py:183/embedding.py:318 同款用法)。
warning 不会打扰 env 用户:_load_pg_dsn 保留现有的剥离逻辑,from_env 传给构造函数时 DSN 已经干净,__post_init__ 无事可做。只有手工构造传了带后缀的 DSN 才会触发。三项目 .env 里那些 SQLAlchemy 写法不会每次装配刷一条 warning。
代价是同一件事有两处剥离逻辑。用同一个模块级 helper _strip_dsn_driver(dsn) 供两处调用,避免实现分叉。
6. 行为审计
| 现有行为 | 处置 |
|---|---|
from_env 对上述 15 条的校验与报错 |
全部保留,时机提前到 cls(...);_load_* 内联检查删除,避免同一约束两处维护 |
_load_pg_dsn 剥 +driver |
保留,继续只在 env 路径生效(§5) |
_load_choice 的 default 语义(键缺失时取默认) |
保留,那是 env 解析职责,不是不变量 |
| 直接构造出上述任一非法组合 → 静默成功 | 有意替换为构造期 ValueError |
client.py 5 处 assert |
保留,仅改注释(§4) |
| 异常类型 | 一律 ValueError,与第一轮及既有装配错误一致 |
有意放弃:不校验 pricing_path 指向的文件是否存在(I/O 不属于配置校验,PricingTable.from_file 自会报错);不强制 cache_backend == "none" 时 namespace/ttl 必须为 None(多余字段无害)。
7. 非功能维度
与第一轮同构,不重复论证:__post_init__ 纯同步计算无 I/O(不适用并发/取消/持久化);装配期属准入侧,报错不放行;__post_init__ 不改字段故幂等。性能:新增约 10 次字符串比较,第一轮实测单次构造 1.45 µs 且库内无热路径构造 GatewaySettings,可忽略。
8. 测试策略
tests/unit/test_config.py::TestCrossFieldInvariants 扩充(不新建类,同族不变量归一处):
| 用例组 | 断言 |
|---|---|
| 6 条枚举各一条非法值 | 抛 ValueError,消息含字段名与合法域 |
| redis_url 三条件(limiter/breaker/cache 各一) | 抛 ValueError,消息点明需要 redis_url |
| cache namespace 缺失 / ttl ≤ 0 | 抛 ValueError |
| telemetry sqlite path / pg dsn 缺失 | 抛 ValueError |
structured_max_retries=-1、scope="" |
抛 ValueError |
pg dsn 含 +asyncpg(直接构造) |
后缀被剥,字段值为干净 DSN,且发出一条 warning(用 caplog/loguru sink 断言) |
pg dsn 干净(直接构造)、或经 from_env 传入 |
不发 warning——env 路已在 _load_pg_dsn 剥过,不该刷噪音 |
| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 |
回归护栏:GatewayClient.from_settings 走 redis 三后端的合法配置 |
装配成功,证明 assert 前提真的被保证了 |
TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。
版本:1.0.2(patch),CHANGELOG 同样单列"行为收紧"小节。
9. 人类拍板结论(2026-07-30)
| 问题 | 结论 |
|---|---|
| §5 DSN 处置 | 选 C:构造期剥后缀 + logger.warning。不静默改用户的值,也不让两条装配路产出不一致 |
| §4 assert 处置 | 保留,只改注释,点明由哪个方法保证前提 |
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
| 版本 | 1.0.2(patch) |
| 范围追加(实施中经 verifier 发现后拍板) | G1-G4 四条同族遗漏一并纳入本轮;G1 的 scope 规范化取静默小写+strip(不告警——from_env 一直静默小写,scope 大小写不承载语义) |
10. 实施留痕
分支 fix/settings-invariants-round-2。TDD 两段:主体 15 条先 16 failed、G1-G4 追加 9 failed,实现后全绿(547 passed / 14 skipped,1.0.1 基线 516)。
10.1 独立 verifier 的关键发现
第一次核验判有阻塞,已修:
- 阻塞(本轮新引入):DSN 剥离的 warning 打印了完整连接串,含明文密码,而库内此前从无任何地方打印连接串——违反 P5。已改为只报 scheme 段变化,并补回归测试断言密码与 host/path 不进日志。
- 变异测试 27/28 被杀,唯一存活的是
_load_pgw里PGW_CACHE_BACKEND域检查删掉后仍全绿(该 env 层 raise 零覆盖)。已补test_cache_backend_whitelist,与既有test_telemetry_backend_whitelist对称。 - assert 处置经独立核验成立:遍历所有可达构造路径均无法制造 assert 失败,
python -O下同样在构造期被拦(旧病症消失);唯一能触发的是object.__new__绕过__post_init__的人造路径,非公共 API。 - frozen 语义无副作用:
object.__setattr__后hash/相等性/集合去重正常,replace幂等不重复告警,pickle/deepcopy不触发__post_init__故不重复告警,对外仍抛FrozenInstanceError。
10.2 G1-G4:第三批遗漏(已纳入本轮)
verifier 通读 _load_* 后发现,除设计 §1 的 15 条外还有四条规范化只在 env 路生效——与本轮所修的 DSN 是同一类:
| 内容 | 危害 | |
|---|---|---|
| G1 | scope 小写化 |
最严重:scope 进 Redis key,大小写不一致使限流/熔断状态分裂到两套命名空间,分布式治理静默失效 |
| G2 | redis_url 空串归 None |
空串骗过 is None,退化为 redis 客户端的连接串天书报错——正是本轮 CHANGELOG 声称已消除的那种 |
| G3 | pricing_path 空串归 None |
退化为 Is a directory: '.' |
| G4 | EmbeddingSettings.batch_size/expected_dim 域 |
该类无 __post_init__;晚一步到 client 构造才 fail-loud |
统一收进新增的 GatewaySettings._normalize()(在全部 _validate_* 之前跑)与 EmbeddingSettings.__post_init__。DSN 后缀因需看 backend 且需告警,规范化留在 _validate_telemetry。