Files
PolyGateway/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md
T
iomgaa a65b504a3d fix: consolidate remaining assembly validation into GatewaySettings
Round two of the from_env-only validation problem. Fifteen checks still lived
in the env parsing functions: six enum domains, the redis_url requirement for
redis-backed limiter/breaker/cache, cache namespace and TTL, telemetry path and
DSN, non-negative structured retries and non-blank scope. from_settings and
direct construction bypassed all of them.

The five asserts in client.py that claimed config had already validated
redis_url and the telemetry targets now hold on every path, so they revert to
what CLAUDE.md permits: internal invariant declarations that also narrow the
Optional for type checkers. Their comments now name the method that guarantees
them, since the previous wording is exactly what went stale.

Postgres DSNs built by hand now get the SQLAlchemy +driver suffix stripped the
way from_env has always stripped it, with a warning so the rewrite is not
silent. The env path strips earlier, so it stays quiet.
2026-07-30 00:58:33 -04:00

8.7 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 即报错 显式,库不碰用户给的值;但两条装配路对同一输入接受度不同
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_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(直接构造) 后缀被剥,字段值为干净 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)