Files
PolyGateway/research-wiki/plans/2026-08-06-governance-backend-error-plan.md
T
iomgaa 1fa91cf73d docs: add the implementation plan for issue #7
Five tasks, with the ARCHITECTURE section 6.1 revision first so the code
never contradicts the single source of truth, and the reparenting kept
atomic because scope is a required keyword argument and any split would
leave an unrunnable tree.

The Codex review caught that the planned test evidence pointed at the
wrong stubs: the ones at test_backpressure.py:176-186 cover accounting-side
degradation, not the three gate paths that actually leak to callers, and
try_acquire and try_enter have no stub at all.
2026-08-06 04:11:17 -04:00

16 KiB

实现计划: 治理后端故障归位为 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 新增与变更部分:

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 必填):

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.pysrc/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__ 保持字母序: SourceDeadErrorSourceNotConfiguredErrorTransientError,即插在 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.pybackends/redis/limiter.pybackends/redis/breaker.pybackends/memory/limiter.pymiddleware/ratelimit.pymiddleware/breaker.pymiddleware/retry.pyocr.pyembedding.py;改 tests/unit/test_errors.pytests/unit/test_backpressure.pytests/unit/test_redis_key_layout.pytests/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 同上
  1. 后端层 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 处)
  2. 两个 gate 包装器: 构造函数改为上文"关键接口"的签名;QuotaGate 4 处(ratelimit.py:30/38/46/54)与 BreakerGate 5 处(breaker.py:26/36/46/54/62)的 raisescope=self._scope

    • 各方法开头的 except GovernanceBackendError: raise 保持不变(后端层已填好 scope,重建实例只会重复构造,设计 §3.3)。
  3. 三处装配各传 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/ -q0 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
  1. 在该表下补一句说明: TransientError / SourceDeadError 的 docstring 描述的是库内治理行为,它们被 middleware/retry.py:365 接住并在预算耗尽时包成 AllSourcesExhausted,不会到达调用方——issue #7 记载下游曾据此写错整段设计文档。
  2. 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.61.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。