The independent verifier found 15 more checks still living only in from_env: six enum domains, seven conditional-required pairs and two scalar ranges. More severe than round one because client.py has five asserts that claim config already validated the redis_url and telemetry paths, which is false on the from_settings path. Also records a normalisation gap the verifier missed: _load_pg_dsn strips the SQLAlchemy +asyncpg suffix, so a hand-built DSN reaches asyncpg unstripped.
7.9 KiB
GatewaySettings 装配校验补齐(第二轮)
- 日期: 2026-07-30;状态: 待人类审批(同为构造承诺收紧,强制人类门)
- 缘起: 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 即报错(推荐) |
显式优于隐式;frozen dataclass 里改字段要 object.__setattr__,是在用户背后动他给的值。报错消息直接告诉他去掉后缀即可 |
B. 构造期规范化(object.__setattr__ 剥后缀) |
与 from_env 行为完全对齐,调用方不用管;但 frozen 类在构造期悄悄改字段,后续 replace/相等性比较都会有惊喜 |
推荐 A。代价是两条装配路对同一输入的接受度不同(from_env 接受带后缀的、直接构造不接受),但两者的产出一致——GatewaySettings.telemetry_pg_dsn 永远是干净 DSN。这个不对称是有意的:env 那条路要吃下三项目历史遗留的 SQLAlchemy 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 |
抛 ValueError,消息给出去后缀的写法 |
| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 |
回归护栏:GatewayClient.from_settings 走 redis 三后端的合法配置 |
装配成功,证明 assert 前提真的被保证了 |
TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。
版本:1.0.2(patch),CHANGELOG 同样单列"行为收紧"小节。
9. 待人类拍板
- §5 的 DSN 处置:选 A(校验拒绝,推荐)还是 B(构造期剥后缀)?
- §4 的 assert 保留 + 改注释,是否认同?