# GatewaySettings 装配校验补齐(第二轮) - **日期**: 2026-07-30;**状态**: **已批准并实施**(2026-07-30 人类门通过;§9 结论、§10 实施留痕) - **缘起**: [2026-07-29-settings-invariant-guards-design.md](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 处断言**明文声称这个前提已经成立**: ```python 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 解析职责,不是不变量 | | `_load_breaker` 的有效阈值派生 `max(配置值, 源级并发×2)` | **有意保留在 env 层**(verifier 二次核验点名,记此备案免成"第五批")。它是**派生**不是校验/规范化:两路产出确实不同(env 装配 threshold=5/并发=100 得 200,直接构造得 5),但派生依赖的是"用户没显式表态时库替他选一个合理值"的 env 语义;代码构造那条路,调用方给什么就是什么表态。其跨字段下限风险由 `_validate_probe` 在构造期兜底 | | 直接构造出上述任一非法组合 → 静默成功 | **有意替换**为构造期 `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`。