diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f3129..f3cefcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.0.2(2026-07-30) -1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 `from_env` 上还留着同一类的 15 条校验,一并收拢。 +1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 `from_env` 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。 ### 修复 @@ -18,6 +18,8 @@ 同 1.0.1:经 `from_env()` 装配的调用方**不受影响**。手工构造 `GatewaySettings` 或对它 `dataclasses.replace` 的调用方,若配置组合非法,现在会在构造期抛 `ValueError` 并点出字段名,而不是留到运行时表现为静默不建后端、裸 `AssertionError` 或第三方库的天书报错。 +**一处静默改值需要留意**:此前手工构造传 `scope="LLM"`(非全小写)的调用方,升级后 scope 会被规范化为 `llm`,**Redis key 随之从 `pgw:limit:LLM:…` 切到 `pgw:limit:llm:…`**。这正是本次要修的问题——旧行为下这批 key 与 `from_env` 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。 + ## 1.0.1(2026-07-30) ### 修复 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 f4df5d0..f130ef7 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 @@ -96,6 +96,7 @@ assert settings.redis_url is not None # 内部不变量: config 已校验 | `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`,与第一轮及既有装配错误一致 | diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 14219e0..7ab9a60 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -171,6 +171,11 @@ class TestAssemblyGuards: s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0")) assert s2.structured_max_retries == 0 + def test_negative_structured_retries_rejected_with_env_key(self): + """env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。""" + with pytest.raises(ValueError, match="PGW_STRUCTURED_MAX_RETRIES"): + GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="-1")) + def test_cache_requires_namespace_and_ttl(self): env = _env(PGW_CACHE_BACKEND="memory") with pytest.raises(ValueError, match="NAMESPACE"): diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py index 65e548a..2a6e78e 100644 --- a/tests/unit/test_embedding.py +++ b/tests/unit/test_embedding.py @@ -358,6 +358,11 @@ class TestEmbeddingSettings: s = EmbeddingSettings.from_env("EMBED", env=env) assert s.normalize is True and s.expected_dim == 768 + def test_expected_dim_must_be_positive(self): + """env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。""" + with pytest.raises(ValueError, match="EXPECTED_DIM"): + EmbeddingSettings.from_env("EMBED", env={**self._ENV, "EMBED__EXPECTED_DIM": "0"}) + def test_from_settings_assembles_client(self): s = EmbeddingSettings.from_env("EMBED", env=self._ENV) client = EmbeddingClient.from_settings(s)