diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a170ae..76f3129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ - **后端选择与条件必填项在任何构造路径上都校验。** 以下此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行:`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full` 六个字段的合法域;取 `redis` 的后端必须有 `redis_url`;启用缓存必须有 `cache_namespace` 与正 `cache_ttl_s`;`telemetry_backend` 取 `sqlite`/`postgres` 时对应的路径/DSN 必填;`structured_max_retries` 非负;`scope` 非空。 - **`client.py` 五处断言的前提现在真的成立。** `assert settings.redis_url is not None # 内部不变量: config 已校验` 之类的注释此前在 `from_settings` 路上是假的:断言开启时抛不含任何字段信息的 `AssertionError`,`python -O` 下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。 -- **手工构造时的 Postgres DSN 会剥掉 SQLAlchemy 驱动后缀。** `postgresql+asyncpg://…` 中的 `+asyncpg` asyncpg 不认;`from_env` 一直会剥,直接构造那条路此前不剥,DSN 会一路带到首次写遥测时才炸。现在两条路产出一致,且构造期剥的时候会发一条 warning——库动了调用方给的值,不该静默。经 `from_env` 装配不受影响也不会有这条 warning。 +- **构造路补齐了 `from_env` 一直在做的规范化**,两条装配路对同一输入产出同一个值: + - `scope` 小写并去空白。它直接进 Redis key(`pgw:limit:{scope}:…`、`pgw:gate:{scope}:…`),此前一个进程走 `from_env("LLM")` 拿到 `llm`、另一个直接构造传 `"LLM"`,**同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间**,各记各的配额与熔断状态,分布式治理静默失效且不报错。 + - `redis_url`、`pricing_path` 的空串归 `None`。留着空串会骗过 `is None` 判断,把错误推迟成 redis 客户端的连接串解析异常或 `Is a directory: '.'`。 + - Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://…` 的 `+asyncpg` asyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经 `from_env` 装配的不受影响也不会有这条 warning(`_load_pg_dsn` 早就剥干净了)。 +- **`EmbeddingSettings` 的 `batch_size` / `expected_dim` 域校验也移入构造期**,此前只有 `EmbeddingSettings.from_env` 校验,直接构造出 `batch_size=-3` 要到 `EmbeddingClient` 构造时才 fail-loud。 ### 行为收紧(下游请读) diff --git a/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md b/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md index 5968c1a..f4df5d0 100644 --- a/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md +++ b/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md @@ -1,6 +1,6 @@ # GatewaySettings 装配校验补齐(第二轮) -- **日期**: 2026-07-30;**状态**: 待人类审批(同为构造承诺收紧,强制人类门) +- **日期**: 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(装配只有两条路) @@ -134,3 +134,30 @@ TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现 | §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`。 diff --git a/src/polygateway/config.py b/src/polygateway/config.py index de7c088..5cfbc67 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -129,6 +129,7 @@ class GatewaySettings: lease_ttl_s: float def __post_init__(self) -> None: + self._normalize() self._validate_identity() self._validate_backends() self._validate_cache() @@ -137,6 +138,24 @@ class GatewaySettings: self._validate_stall() self._validate_probe() + def _normalize(self) -> None: + """把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。 + + `scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:…`/`pgw:gate:{scope}:…`)。 + 一个进程走 `from_env("LLM")` 拿到 "llm"、另一个直接构造传 "LLM",同一逻辑 + scope 的限流与熔断状态会分裂到两套命名空间,各记各的,治理静默失效且不报错。 + + 空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端 + 抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警, + 规范化留在 `_validate_telemetry`。 + """ + normalized_scope = self.scope.strip().lower() + if normalized_scope != self.scope: + object.__setattr__(self, "scope", normalized_scope) + for field in ("redis_url", "pricing_path"): + if getattr(self, field) == "": + object.__setattr__(self, field, None) + def _validate_identity(self) -> None: """本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。""" if not self.scope.strip(): @@ -473,6 +492,13 @@ class EmbeddingSettings: normalize: bool = False expected_dim: int | None = None + def __post_init__(self) -> None: + """自身字段的域校验;内嵌的 gateway 由 `GatewaySettings.__post_init__` 自己把关。""" + if self.batch_size < 1: + raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}") + if self.expected_dim is not None and self.expected_dim < 1: + raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}") + @classmethod def from_env( cls, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 64d58ba..14219e0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,7 +7,7 @@ import pytest from loguru import logger from polygateway.client import GatewayClient -from polygateway.config import GatewaySettings, OcrSettings +from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings _BASE_ENV = { "LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1", @@ -271,6 +271,11 @@ class TestAssemblyGuards: with pytest.raises(ValueError, match="TELEMETRY_BACKEND"): GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql")) + def test_cache_backend_whitelist(self): + """对称于上一条: env 层的域检查保留是为了报错能点出键名,得有测试守着。""" + with pytest.raises(ValueError, match="CACHE_BACKEND"): + GatewaySettings.from_env("LLM", env=_env(PGW_CACHE_BACKEND="rediss")) + def test_pricing_path_optional(self): assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json")) @@ -552,6 +557,51 @@ class TestCrossFieldInvariants: assert settings.telemetry_pg_dsn == "postgresql://u@h/db" assert not warnings + # —— 构造期规范化: env 路一直在做的,构造路也要做(否则两条路产出不同的值)—— + + @pytest.mark.parametrize("raw", ["LLM", " llm ", " LLM "]) + def test_scope_normalized_on_direct_construction(self, raw): + """scope 直接进 Redis key(pgw:limit:{scope}:…)。 + + 大小写不一致会让同一逻辑 scope 的限流/熔断状态分裂到两套命名空间—— + 两边各记各的配额与熔断状态,分布式治理静默失效且不报错。 + """ + base = self._base() + assert dataclasses.replace(base, scope=raw).scope == "llm" + + def test_blank_redis_url_normalized_to_none(self): + """空串此前只有 env 路归 None,构造路留着它骗过 `is None` 判断。""" + base = self._base() + assert dataclasses.replace(base, redis_url="").redis_url is None + + def test_blank_redis_url_still_blocks_redis_backend(self): + """归 None 后必须落进条件必填,而不是放行到 redis 库去抛连接串天书。""" + base = self._base() + with pytest.raises(ValueError, match="redis_url"): + dataclasses.replace(base, limiter_backend="redis", redis_url="") + + def test_blank_pricing_path_normalized_to_none(self): + base = self._base() + assert dataclasses.replace(base, pricing_path="").pricing_path is None + + # —— EmbeddingSettings 自身的字段域(此前只有 from_env 校验)—— + + @pytest.mark.parametrize("bad", [0, -3]) + def test_embedding_settings_rejects_non_positive_batch_size(self, bad): + base = self._base() + with pytest.raises(ValueError, match="batch_size"): + EmbeddingSettings(gateway=base, batch_size=bad) + + def test_embedding_settings_rejects_non_positive_expected_dim(self): + base = self._base() + with pytest.raises(ValueError, match="expected_dim"): + EmbeddingSettings(gateway=base, batch_size=8, expected_dim=0) + + def test_embedding_settings_accepts_valid_values(self): + base = self._base() + settings = EmbeddingSettings(gateway=base, batch_size=8, expected_dim=1024) + assert settings.batch_size == 8 and settings.expected_dim == 1024 + # —— 回归护栏: client.py 的 assert 前提确实被保证了 —— def test_factory_accepts_valid_redis_stack(self):