# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7) - **设计**: `research-wiki/designs/2026-08-06-governance-backend-error-design.md`(已批准 2026-08-06,Q1/Q2/Q3 逐条拍板) - **分支**: `feat/issue-7-governance-backend-error` - **目标**: 让"限流/熔断后端故障"在类型上落入 `GatewayUnavailableError`,使调用方一条 `except` 覆盖完整;同时把混在同一类里的装配缺陷拆出去,避免配置写错的任务永远重投。 - **方案概述**: `GovernanceBackendError` 改继承 `GatewayUnavailableError`(新 reason `governance_backend_down`,`retry_after_s` 默认 5.0);两处"未知源"改抛新增的 `SourceNotConfiguredError`(**不**在 scope 级家族内);`scope` 由后端层 `self._scope` 与两个 gate 包装器注入。 - **涉及技术**: Python 3.11+,pytest(含真实 Redis 的 integration),radon/ruff 门禁。 ## 保真校验(适用) 本计划触及 ARCHITECTURE.md §1.4 索引的移植蓝本:错误分类(`reference/CHSAnalyzer/app/domain/errors.py`)与限流/熔断(`reference/CHSAnalyzer/app/coordination/`)。 本次**有意变更**的语义只有一条,已在设计 §3.1 声明:`GovernanceBackendError` 的类型归属(CHS 的 `LimiterError` 是独立异常,本库将其提升为 scope 级不可用的一员)。除此之外,下列承自 CHS 的语义**不得被顺带改动**,每个任务完成前逐条自查: | 不得改动 | 出处 | |---|---| | `retry_after_s` 非可选、`0 = 可立即重试` | `errors.py:74-78` | | `SCOPE_REASONS` 既有 5 值与 `SOURCE_REASONS` 既有 7 值 | `errors.py:7-20` | | fail-closed 降级方向(限流/熔断后端挂 → 报错而非放行) | 库铁律 | | 记账路径降级为 warning、闸门路径上抛的分工 | `middleware/retry.py:404` | | `RedisPermit.release/settle` 的释放侧降级 | `backends/redis/limiter.py:133,151` | ## 文件结构 | 文件 | 职责 | 动作 | |---|---|---| | `research-wiki/ARCHITECTURE.md` | 架构单一事实源 §6.1 错误分类表 | 修改(**必须先行**,见设计 §8.1) | | `src/polygateway/errors.py` | 错误类型树内核 | 修改: 新常量、新 reason、新类、继承变更 | | `src/polygateway/__init__.py` | 公共 API 面 | 修改: 导出新类 | | `src/polygateway/backends/redis/limiter.py` | Redis 限流后端 | 修改: 6 处补 scope、1 处换新类 | | `src/polygateway/backends/redis/breaker.py` | Redis 熔断后端 | 修改: 5 处补 scope | | `src/polygateway/backends/memory/limiter.py` | 内存限流后端 | 修改: 1 处换新类 | | `src/polygateway/middleware/ratelimit.py` | `QuotaGate` 包装器 | 修改: 构造增 scope、4 处补 scope | | `src/polygateway/middleware/breaker.py` | `BreakerGate` 包装器 | 修改: 构造增 scope、5 处补 scope | | `src/polygateway/middleware/retry.py` / `ocr.py` / `embedding.py` | 三处 gate 装配 | 修改: 各 2 行传 scope | | `tests/unit/test_errors.py` | 错误类型契约 | 修改 | | `tests/unit/test_backpressure.py` | 后端故障传播 | 修改 | | `tests/unit/test_redis_key_layout.py` | 未知源行为 | 修改 | | `tests/integration/test_redis_cross_connection.py` | 真实 Redis 掉线 | 修改 | | `README.md` / `research-wiki/migrations/chsanalyzer.md` / `CHANGELOG.md` / `pyproject.toml` | 文档与版本 | 修改 | ## 关键接口(跨任务消费,此处写死) `errors.py` 新增与变更部分: ```python GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0 """治理后端故障的建议重投间隔(秒)。 **不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期 时刻),故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让 积压任务零延迟同时冲击已挂掉的后端(issue #7 §3.2)。 """ class SourceNotConfiguredError(PolyGatewayError): """源名不在限流后端的配置字典中: 装配缺陷,正常不可达。 **有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是 "配置写错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置 错误的任务永远重投、永不告警(issue #7 §3.4)。 """ class GovernanceBackendError(GatewayUnavailableError): """限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。 继承 `GatewayUnavailableError`: fail-closed 时一个请求都发不出去,语义 上即 scope 级不可用,调用方一条 except 即可覆盖(issue #7)。 """ def __init__( self, message: str, *, scope: str, retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S, source_name: str | None = None, ) -> None: super().__init__( scope=scope, reason="governance_backend_down", retry_after_s=retry_after_s, source_name=source_name, ) # 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点 # 携带的诊断串是排障主线索,必须保住(设计 §3.5,机制已实跑验证) self.args = (message,) ``` 两个 gate 包装器的构造签名(`scope` 为 keyword-only 必填): ```python class QuotaGate: def __init__(self, limiter: RateLimiter, *, scope: str) -> None: self._limiter = limiter self._scope = scope class BreakerGate: def __init__(self, gate: ProviderGate, *, scope: str) -> None: self._gate = gate self._scope = scope ``` --- ## 任务清单 ### - [ ] T1: ARCHITECTURE §6.1 回补(必须先行) **文件**: `research-wiki/ARCHITECTURE.md`(§6.1,约 372-380 行) **行为**: 在错误分类表补两行——`GovernanceBackendError`(scope 级不可用,reason 恒为 `governance_backend_down`)与 `SourceNotConfiguredError`(装配缺陷,不重试不换源,消耗失败预算);scope 级 `reason` 值域由 5 值扩为 6 值,增 `governance_backend_down`。同时记录本次归位的理由与日期,并说明根因(该类是 M2 引入分布式后端时新增,当时未回补本表)。 **为什么先行**: `ARCHITECTURE.md` 是单一事实源,新 reason 值域与其现状冲突;先改代码后补文档等于让实现与事实源脱节(设计 §8.1)。 **验收**: §6.1 表格含上述两行;reason 值域文字与 `errors.py` 将要写入的 `SCOPE_REASONS` 逐字一致。 **测试要求**: 纯文档,无测试证据要求。 **验证**: `grep -n "governance_backend_down\|SourceNotConfiguredError" research-wiki/ARCHITECTURE.md` → 至少各 1 处命中。 **提交**: `docs: admit governance backend failures into the scope-level error model` --- ### - [ ] T2: errors.py 纯增量(新常量、新 reason、新类)+ 导出 **文件**: 改 `src/polygateway/errors.py`、`src/polygateway/__init__.py`;改 `tests/unit/test_errors.py` **行为**: 1. 加模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`(docstring 逐字见上文"关键接口"); 2. `SCOPE_REASONS` 增 `"governance_backend_down"`; 3. 新增 `SourceNotConfiguredError(PolyGatewayError)`(定义逐字见上文); 4. `__init__.py` 的 import 块与 `__all__` 各增 `SourceNotConfiguredError`(`__all__` 保持字母序: `SourceDeadError` → **`SourceNotConfiguredError`** → `TransientError`,即插在 `SourceDeadError` **之后**)。 **本任务不动 `GovernanceBackendError`**——它是纯增量,不破坏任何既有调用点,可独立提交且全套件保持通过。 **测试要求(先失败后通过)**: - 新增用例断言 `SourceNotConfiguredError` **不是** `GatewayUnavailableError` 的子类,且是 `PolyGatewayError` 的子类。改前该类不存在 → `ImportError`;改后 PASS。 - 新增用例断言 `"governance_backend_down" in SCOPE_REASONS`,且 `GatewayUnavailableError(scope="llm", reason="governance_backend_down", retry_after_s=0.0)` 可构造。改前 `reason` 校验抛 `ValueError` → 用例失败;改后 PASS。 - 新增用例断言 `from polygateway import SourceNotConfiguredError` 可用。 **验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS;`conda run -n PolyGateway pytest tests/ -q` → 与改动前同样全绿(纯增量不应影响任何既有用例)。 **提交**: `feat: add SourceNotConfiguredError and the governance backend reason` --- ### - [ ] T3: `GovernanceBackendError` 归位 + 22 处构造点 + scope 注入(原子) **文件**: 改 `src/polygateway/errors.py`、`backends/redis/limiter.py`、`backends/redis/breaker.py`、`backends/memory/limiter.py`、`middleware/ratelimit.py`、`middleware/breaker.py`、`middleware/retry.py`、`ocr.py`、`embedding.py`;改 `tests/unit/test_errors.py`、`tests/unit/test_backpressure.py`、`tests/unit/test_redis_key_layout.py`、`tests/integration/test_redis_cross_connection.py` **为什么必须原子**: `scope` 是必填 keyword,继承变更与全部构造点若分批提交,中间状态会 `TypeError`,门禁跑不过。 **行为**: 1. `errors.py`: `GovernanceBackendError` 改继承 `GatewayUnavailableError` 并覆写 `__init__`(逐字见上文"关键接口")。 2. **两处未知源改抛新类**(设计 §3.4,Q1 已拍板): | 位置 | 改为 | |---|---| | `backends/redis/limiter.py:198` | `raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")` | | `backends/memory/limiter.py:92` | 同上 | 3. **后端层 11 处补 `scope=self._scope`**(该属性已存在: redis limiter `:170`、redis breaker `:291`、memory limiter 同名字段): - `backends/redis/limiter.py` 的 `:250 / :268 / :275 / :286 / :298 / :305`(6 处) - `backends/redis/breaker.py` 的 `:370 / :388 / :410 / :422 / :432`(5 处) 4. **两个 gate 包装器**: 构造函数改为上文"关键接口"的签名;`QuotaGate` 4 处(`ratelimit.py:30/38/46/54`)与 `BreakerGate` 5 处(`breaker.py:26/36/46/54/62`)的 `raise` 补 `scope=self._scope`。 - 各方法开头的 `except GovernanceBackendError: raise` **保持不变**(后端层已填好 scope,重建实例只会重复构造,设计 §3.3)。 5. **三处装配各传 scope**(三处的 `self._scope` 均已在装配前赋值,无需调整顺序): | 文件 | 行 | 改为 | |---|---|---| | `middleware/retry.py` | 186-187 | `QuotaGate(limiter, scope=self._scope)` / `BreakerGate(gate, scope=self._scope)` | | `ocr.py` | 122-123 | 同款 | | `embedding.py` | 123-124 | 同款 | **测试要求(先失败后通过,逐条对应)**: | 用例 | 文件 | 改前为何失败 | |---|---|---| | `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住,且 `reason == "governance_backend_down"`、`retry_after_s == 5.0` | `tests/unit/test_errors.py` | 改前非其子类,`pytest.raises(GatewayUnavailableError)` 不匹配 | | `str(exc)` 仍为构造时的诊断串(防 §3.5 回归) | `tests/unit/test_errors.py` | 改前无该风险但改后若漏写 `self.args` 即失败,是回归护栏 | | 三条泄漏路径(`try_acquire` / `try_enter` / `progress_age_s`)抛出的异常带正确 `scope`、且可被 `except GatewayUnavailableError` 接住 | `tests/unit/test_backpressure.py` — **三条都要新增桩**。现状: `progress_age_s` 只有 `TestQuotaGateProgressAge`(`:243-257`)覆盖包装行为、不验 scope;`try_acquire`(`QuotaGate`)与 `try_enter`(`BreakerGate`)**完全无桩** | 改前异常无 `scope` 属性 → `AttributeError`;两条新路径改前无覆盖 | | 未知源抛 `SourceNotConfiguredError`,且断言它**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`(`test_unknown_source_rejected`,现断言 `GovernanceBackendError`);内存版**当前无对应用例,需新增**一条同款(`backends/memory/limiter.py:92` 的 `_cfg("nope")`) | 改前 redis 版类型断言失败;内存版改前无覆盖(该分支从未被测过) | | Redis 真实掉线时准入侧抛 scope 级异常且 `reason == "governance_backend_down"` | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 改前无 `reason` 属性 | **必须同批更新的既有测试构造点**(新签名为 keyword-only 必填,漏改即 `TypeError: missing required keyword-only argument`,门禁直接红): | 位置 | 现状 | 改为 | |---|---|---| | `tests/unit/test_backpressure.py:176 / :181 / :186` | `raise GovernanceBackendError("redis 抖动")` | 补 `scope=`(任意测试 scope,如 `"llm"`) | | `tests/unit/test_errors.py:89` | `exc = GovernanceBackendError("redis down")` | 同上;该用例现断言它**不属于**可重试分类,须一并改为断言它**是** `GatewayUnavailableError` | | `tests/unit/test_backpressure.py:255 / :257` | `QuotaGate(_L())` / `QuotaGate(_Broken())` | `QuotaGate(_L(), scope="llm")` 等 | **保真校验检查点**: 提交前对照上文"保真校验"五条逐条自查,确认无一被顺带改动。特别核对 `RedisPermit.release/settle`(`redis/limiter.py:133,151`)的 `except GovernanceBackendError` 仍能接住释放侧失败——该处是设计 §4 否决"让原始异常穿透"路线的直接原因。 **验证**: - `conda run -n PolyGateway pytest tests/unit tests/contracts -v` → 全 PASS - `conda run -n PolyGateway pytest tests/integration -v` → 全 PASS(需真实 Redis) - `conda run -n PolyGateway pytest tests/ -q` → `0 failed` - `conda run -n PolyGateway radon cc src -n C -s` → 无输出 - `make lint` → import-linter 契约全绿(本次不新增跨层依赖,应无变化) **提交**: `fix: reparent governance backend failures under GatewayUnavailableError (issue #7)` --- ### - [ ] T4: 公开错误面文档(issue #7 第二诉求) **文件**: 改 `README.md`(§"错误模型(四分类)",约 114-125 行)、`research-wiki/migrations/chsanalyzer.md` **行为**: 1. README 增一张两列表,明确区分**会到达调用方**与**库内吸收**: | 会到达调用方 | 库内吸收 | |---|---| | `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError`) | `TransientError` | | `RequestRejectedError` | `SourceDeadError` | | `ResultInvalidError` | | | `SourceNotConfiguredError` | | 2. 在该表下补一句说明: `TransientError` / `SourceDeadError` 的 docstring 描述的是**库内治理行为**,它们被 `middleware/retry.py:365` 接住并在预算耗尽时包成 `AllSourcesExhausted`,**不会**到达调用方——issue #7 记载下游曾据此写错整段设计文档。 3. `migrations/chsanalyzer.md` 的 G1 条目补注:后端故障现已并入 `GatewayUnavailableError`,项目侧 `except GatewayUnavailableError` 一条即覆盖完整,无需为 `GovernanceBackendError` 单列分支。 **验收**: 调用方仅读 README 即可判断该 catch 什么,无需读 `middleware/retry.py`。 **测试要求**: 纯文档,无测试证据要求。 **验证**: `grep -n "库内吸收" README.md` → 命中。 **提交**: `docs: publish which errors reach callers and which the library absorbs` --- ### - [ ] T5: 版本 1.1.0 + CHANGELOG + Wiki 同步 **文件**: 改 `pyproject.toml`(version)、`src/polygateway/__init__.py`(`__version__`)、`CHANGELOG.md`;按 `research-wiki/docs-convention.md` §2 同步 Gitea Wiki **行为**: 版本 `1.0.6` → `1.1.0`(有行为变更但无 API 破坏:加父类是扩大)。CHANGELOG 需写明: - **行为变更**: 后端故障从"落入调用方兜底分支"变为"被 `except GatewayUnavailableError` 捕获";下游据此把它按"延期重投、不消耗失败预算"处置,这正是修复目标,但**处置路线确实变了**,升级前须确认下游的兜底分支没有依赖它。 - **新增**: `SourceNotConfiguredError`(公共导出)、`GOVERNANCE_BACKEND_RETRY_AFTER_S`、scope 级 reason `governance_backend_down`。 - **下游请读**: `GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s`(默认 5.0)/`per_source_reasons`;`str(exc)` 仍是原诊断串,结构化字段并存。配置写错(源名不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`,它**不**属于可重投家族——这是有意的,目的是让装配缺陷进死信而不是永远重投。 **验收**: 版本三处一致(`pyproject.toml` / `__init__.py` / CHANGELOG 标题);CLAUDE.md §6 要求"版本 bump 提交不得裸发",故本任务必须与 wiki 同步同批。 **测试要求**: 无行为变更,`pytest tests/ -q` 保持全绿即可。 **验证**: `grep -n "1.1.0" pyproject.toml src/polygateway/__init__.py CHANGELOG.md` → 三处命中。 **提交**: `chore: release 1.1.0` --- ## 完成后 按 CLAUDE.md §3 Phase 2,合并前须派**全新上下文**的 verifier subagent 做独立验证(`verification-before-completion`),并按新规则**前台运行**。随后走 `finishing-a-development-branch` 决定合并方式,并在 Gitea 关闭 issue #7。