diff --git a/CHANGELOG.md b/CHANGELOG.md index f78fa6f..f817e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 未发布 + +### 修复 + +- **装配守卫在任何构造路径上都生效,不再只在 `from_env` 上。** `_guard_lease` + (源超时须 ≤ permit 租约 TTL)与 `_guard_stall`(stall 窗口须 ≥ 最大 TTFT)原先只写在 + `GatewaySettings.from_env` 里,而 CLAUDE.md §4.5 规定装配有两条官方路径 + ——走 `from_settings()` 的调用方能装出违反类不变量的配置,且不报任何错。 + 守卫已挪进 `GatewaySettings.__post_init__`,与 `SourceConfig` 的做法一致。 + 三个 client(Gateway/Ocr/Embedding)各有两个工厂,共六个入口,一并覆盖。 + + **行为收紧,可能影响下游**: 直接构造 `GatewaySettings`(或对它做 + `dataclasses.replace`)时若组合非法,现在会在构造期抛 `ValueError`, + 而不是留到运行时表现为租约先于请求过期、或正常慢首包被误判为卡死。 + 经 `from_env` 装配的调用方不受影响——那条路本来就跑这两个守卫。 + +- 两个守卫的报错文案改为点字段名(`lease_ttl_s`、`backpressure.stall_window_s`), + 环境变量键降为补充信息。原文案只点环境变量键,而不走 env 的调用方从没设过它们。 + ## 1.0.0(2026-07-22) 首个正式版。统一 LLM/VLM/OCR/Embedding 调度与中转库,治理单位为一次模型调用;经 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目全量迁移验收(ARCHITECTURE §11)。 diff --git a/src/polygateway/config.py b/src/polygateway/config.py index 6908c66..88b96d4 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -88,7 +88,15 @@ def _require(env: Mapping[str, str], *keys: str) -> tuple[str, str]: @dataclass(frozen=True) class GatewaySettings: - """一个 scope 的完整装配配置;构造经 from_env 聚合并通过全部守卫。""" + """一个 scope 的完整装配配置;**任何**构造路径都通过全部守卫。 + + 守卫在 `__post_init__` 里跑,而不是在 `from_env` 里。原因是装配有两条官方路径 + (CLAUDE.md §4.5: `from_env()` 与 `from_settings()`),守卫只挂在其中一条上的话, + 另一条就能装出违反本类不变量的配置 —— 类会存在于它自己声称不可能的状态。 + 放构造期还有一个好处: 三个 client(Gateway/Ocr/Embedding)各有两个工厂, + 共六个入口,挂在这里是一处,挂在工厂里是六处要保持同步。 + 与 `SourceConfig.__post_init__` 的做法一致。 + """ scope: str sources: tuple[SourceConfig, ...] @@ -111,6 +119,10 @@ class GatewaySettings: structured_max_retries: int lease_ttl_s: float + def __post_init__(self) -> None: + _guard_lease(self) + _guard_stall(self) + @classmethod def from_env( cls, @@ -140,8 +152,7 @@ class GatewaySettings: quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"), **_load_pgw(env), ) - _guard_lease(settings) - _guard_stall(settings) + # 守卫已在 __post_init__ 里跑过,这里不再重复调用。 return settings @@ -340,22 +351,33 @@ def _load_structured_retries(env: Mapping[str, str]) -> int: def _guard_lease(settings: GatewaySettings) -> None: - """装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。""" + """装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。 + + 文案点字段名而不是只点环境变量键: 守卫在每次构造时都跑,而走 `from_settings` + 的调用方从没设过那些键,让他去"调大 PGW_LEASE_TTL_S"是句没法执行的建议。 + 环境变量键作为补充信息附在后面,给走 `from_env` 的人用。 + """ slowest = max(s.timeout_s for s in settings.sources) if slowest > settings.lease_ttl_s: raise ValueError( - f"源最大 timeout_s({slowest})超过 permit 租约 TTL({settings.lease_ttl_s});" - f"调大 PGW_LEASE_TTL_S 或调小超时" + f"源最大 timeout_s({slowest})超过 permit 租约 TTL(lease_ttl_s=" + f"{settings.lease_ttl_s});调大 lease_ttl_s 或调小源的 timeout_s" + f"(走 from_env 时对应的键是 PGW_LEASE_TTL_S 与 {{SCOPE}}__{{PROVIDER}}__{{N}}__TIMEOUT_S)" ) def _guard_stall(settings: GatewaySettings) -> None: - """装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。""" + """装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。 + + 文案点字段名的理由同 `_guard_lease`。 + """ ttfts = [s.ttft_timeout_s for s in settings.sources if s.ttft_timeout_s is not None] if ttfts and settings.backpressure.stall_window_s < max(ttfts): raise ValueError( - f"stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 " - f"ttft_timeout_s({max(ttfts)});调大 BACKPRESSURE__STALL_WINDOW_S 或调小 TTFT" + f"backpressure.stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 " + f"ttft_timeout_s({max(ttfts)});调大 stall_window_s 或调小 ttft_timeout_s" + f"(走 from_env 时对应的键是 {{SCOPE}}__BACKPRESSURE__STALL_WINDOW_S 与 " + f"{{SCOPE}}__{{PROVIDER}}__{{N}}__TTFT_TIMEOUT_S)" ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5d513ad..0a26b1e 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -319,3 +319,60 @@ class TestOcrSettings: env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"} with pytest.raises(ValueError): OcrSettings.from_env("OCR", env=env) + + +class TestGuardsRunOnEveryConstruction: + """守卫必须在**任何**构造路径上生效,不只是 from_env。 + + 背景: `GatewaySettings` 的 docstring 声称"构造经 from_env 聚合并通过全部守卫", + 而 CLAUDE.md §4.5 明确装配有两条路(`from_env()`/`from_settings()`)。 + 守卫原先只写在 `from_env` 里,于是 `from_settings` 这条官方路径能装出 + 一个违反类不变量的 settings —— 类可以合法地存在于它自己声称不可能的状态。 + """ + + @staticmethod + def _replace(settings, **changes): + """按既有 settings 派生一个改了几项的新 settings(走构造函数,不走 from_env)。""" + import dataclasses + + return dataclasses.replace(settings, **changes) + + def test_lease_guard_runs_on_direct_construction(self): + """租约守卫: 源 timeout_s 超过 lease_ttl_s 时,直接构造也必须报错。""" + base = GatewaySettings.from_env("LLM", env=_env()) + with pytest.raises(ValueError, match="租约|lease"): + self._replace(base, lease_ttl_s=1.0) + + def test_stall_guard_runs_on_direct_construction(self): + """卡死窗口守卫: stall_window_s 小于最大 ttft 时,直接构造也必须报错。""" + import dataclasses + + base = GatewaySettings.from_env( + "LLM", + env=_env( + **{ + "LLM__QWEN__1__TTFT_TIMEOUT_S": "30", + "LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15", + "LLM__BACKPRESSURE__STALL_WINDOW_S": "60", + } + ), + ) + narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0) + with pytest.raises(ValueError, match="stall"): + self._replace(base, backpressure=narrowed) + + def test_valid_settings_still_constructible(self): + """合法组合不受影响 —— 守卫收紧的是错的那些,不是所有直接构造。""" + base = GatewaySettings.from_env("LLM", env=_env()) + assert self._replace(base, lease_ttl_s=base.lease_ttl_s).lease_ttl_s > 0 + + def test_guard_message_names_fields_not_only_env_keys(self): + """报错要点得出字段名。 + + 守卫一旦在每次构造时都跑,一个在代码里拼 settings 的调用方 + (不走 env)会收到这条消息;只点环境变量名会让他去改几个他从没设过的键。 + """ + base = GatewaySettings.from_env("LLM", env=_env()) + with pytest.raises(ValueError) as exc: + self._replace(base, lease_ttl_s=1.0) + assert "lease_ttl_s" in str(exc.value)