Files
PolyGateway/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md
T
iomgaa 8c9e1179bc docs: design second round of settings validation consolidation
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.
2026-07-30 00:46:44 -04:00

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_backendredis 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_retriesscope)并入 _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_dsnfrom_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_choicedefault 语义(键缺失时取默认) 保留,那是 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=-1scope="" ValueError
pg dsn 含 +asyncpg ValueError,消息给出去后缀的写法
合法组合(每种 backend 组合各一) 构造成功——收紧的是错的那些
回归护栏:GatewayClient.from_settings 走 redis 三后端的合法配置 装配成功,证明 assert 前提真的被保证了

TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。

版本:1.0.2(patch),CHANGELOG 同样单列"行为收紧"小节。

9. 待人类拍板

  1. §5 的 DSN 处置:选 A(校验拒绝,推荐)还是 B(构造期剥后缀)?
  2. §4 的 assert 保留 + 改注释,是否认同?