fix: normalise scope and blank strings on the construction path too
The verifier found four more env-only behaviours of the same class the branch
was already fixing. The worst is scope: it goes straight into the Redis keys
(pgw:limit:{scope}, pgw:gate:{scope}), so one process using from_env("LLM")
and another constructing scope="LLM" by hand split the rate limit and breaker
state across two namespaces, each tracking its own quota, with no error.
Blank redis_url and pricing_path now collapse to None as from_env has always
done, so they fall into the required-field checks instead of reaching the redis
client as an unparseable URL. EmbeddingSettings gains the __post_init__ it never
had, moving its batch_size and expected_dim checks off the from_env-only path.
Also adds the cache backend whitelist test that mutation testing showed missing.
This commit is contained in:
+5
-1
@@ -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` 非空。
|
- **后端选择与条件必填项在任何构造路径上都校验。** 以下此前只有 `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 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。
|
- **`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。
|
||||||
|
|
||||||
### 行为收紧(下游请读)
|
### 行为收紧(下游请读)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GatewaySettings 装配校验补齐(第二轮)
|
# 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 类的剩余部分**
|
- **缘起**: [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(装配只有两条路)
|
- **上游依据**: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路)
|
||||||
|
|
||||||
@@ -134,3 +134,30 @@ TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现
|
|||||||
| §4 assert 处置 | **保留,只改注释**,点明由哪个方法保证前提 |
|
| §4 assert 处置 | **保留,只改注释**,点明由哪个方法保证前提 |
|
||||||
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
|
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
|
||||||
| 版本 | 1.0.2(patch) |
|
| 版本 | 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`。
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class GatewaySettings:
|
|||||||
lease_ttl_s: float
|
lease_ttl_s: float
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
self._normalize()
|
||||||
self._validate_identity()
|
self._validate_identity()
|
||||||
self._validate_backends()
|
self._validate_backends()
|
||||||
self._validate_cache()
|
self._validate_cache()
|
||||||
@@ -137,6 +138,24 @@ class GatewaySettings:
|
|||||||
self._validate_stall()
|
self._validate_stall()
|
||||||
self._validate_probe()
|
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:
|
def _validate_identity(self) -> None:
|
||||||
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
|
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
|
||||||
if not self.scope.strip():
|
if not self.scope.strip():
|
||||||
@@ -473,6 +492,13 @@ class EmbeddingSettings:
|
|||||||
normalize: bool = False
|
normalize: bool = False
|
||||||
expected_dim: int | None = None
|
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
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.client import GatewayClient
|
from polygateway.client import GatewayClient
|
||||||
from polygateway.config import GatewaySettings, OcrSettings
|
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||||||
@@ -271,6 +271,11 @@ class TestAssemblyGuards:
|
|||||||
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
||||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
|
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):
|
def test_pricing_path_optional(self):
|
||||||
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
||||||
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
|
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 settings.telemetry_pg_dsn == "postgresql://u@h/db"
|
||||||
assert not warnings
|
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 前提确实被保证了 ——
|
# —— 回归护栏: client.py 的 assert 前提确实被保证了 ——
|
||||||
|
|
||||||
def test_factory_accepts_valid_redis_stack(self):
|
def test_factory_accepts_valid_redis_stack(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user