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.
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
# GatewaySettings 装配校验补齐(第二轮)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-30;**状态**: 待人类审批(同为构造承诺收紧,强制人类门)
|
||||||
|
- **缘起**: [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` 即报错(推荐)** | 显式优于隐式;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. 待人类拍板
|
||||||
|
|
||||||
|
1. §5 的 DSN 处置:选 A(校验拒绝,推荐)还是 B(构造期剥后缀)?
|
||||||
|
2. §4 的 assert 保留 + 改注释,是否认同?
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:settings-invariants-round-2
|
||||||
|
title: "GatewaySettings 装配校验补齐(第二轮)"
|
||||||
|
date: 2026-07-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# GatewaySettings 装配校验补齐(第二轮)
|
||||||
|
|
||||||
|
全文见 [2026-07-30-settings-invariants-round-2-design.md](2026-07-30-settings-invariants-round-2-design.md)。第一轮见 [settings-invariant-guards](settings-invariant-guards.md)。
|
||||||
|
|
||||||
|
- **缘起**: 第一轮交付后独立 verifier 发现 `from_env` 上还留着 15 条同族校验(枚举合法域 6、条件必填 7、标量域 2),`from_settings` 与直接构造全部放行。
|
||||||
|
- **严重性高于第一轮**: `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` 明文依赖这个前提;实测断言开启抛裸 `AssertionError`,`python -O` 下退化为 redis 库天书。
|
||||||
|
- **方案**: 沿用第一轮已批准的方案 A,不重新论证;新增 `_validate_backends/_validate_cache/_validate_telemetry`,枚举合法域上提为模块级常量供 `_load_pgw` 与构造期共用。
|
||||||
|
- **assert 处置**: **保留不改**——前提一旦由构造期保证,它就是 CLAUDE.md §4.3 认可的内部不变量用法且给类型检查器收窄 `str | None`;只改那句会变成谎言的注释,点明由哪个方法保证。
|
||||||
|
- **本轮唯一新决策**: `_load_pg_dsn` 剥 `+asyncpg` 驱动后缀是**规范化**不是校验,直接构造那条路不会剥。选校验拒绝(显式)而非构造期 `object.__setattr__` 剥后缀(在用户背后改 frozen 字段)。两条路接受度不同是有意的:env 路要吃三项目历史遗留的 SQLAlchemy DSN 写法,代码构造路没有历史包袱。
|
||||||
|
- **版本**: 1.0.2(patch),CHANGELOG 单列"行为收紧"小节。
|
||||||
@@ -95,6 +95,11 @@
|
|||||||
"id": "design:settings-invariant-guards",
|
"id": "design:settings-invariant-guards",
|
||||||
"label": "GatewaySettings 跨字段不变量守卫的生效范围",
|
"label": "GatewaySettings 跨字段不变量守卫的生效范围",
|
||||||
"type": "design"
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:settings-invariants-round-2",
|
||||||
|
"label": "GatewaySettings 装配校验补齐(第二轮)",
|
||||||
|
"type": "design"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-07-30 03:44 UTC
|
> 自动生成,更新时间:2026-07-30 04:44 UTC
|
||||||
|
|
||||||
## design (12)
|
## design (14)
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||||
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
||||||
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
|
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
|
||||||
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
||||||
|
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||||
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||||
|
|||||||
@@ -48,3 +48,5 @@
|
|||||||
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
||||||
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
|
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
|
||||||
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
|
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
|
||||||
|
- [2026-07-30 04:44 UTC] 新增 design: GatewaySettings 装配校验补齐(第二轮) (design:settings-invariants-round-2)
|
||||||
|
- [2026-07-30 04:44 UTC] 重建索引: 38 篇页面
|
||||||
|
|||||||
Reference in New Issue
Block a user