Files
PolyGateway/research-wiki/plans/2026-08-06-governance-backend-error-plan.md
T
iomgaa 5853c3f8ff fix: keep the accounting path degrading after the wrapper change
Letting SourceNotConfiguredError through the gate wrappers opened a hole
the recheck caught: _record_quietly only degrades GovernanceBackendError,
so an assembly defect raised from the accounting side would now escape and
destroy a response from a call that had already genuinely succeeded. That
inverts the exact invariant _record_quietly exists to hold.

Widening _record_quietly is the right fix rather than narrowing the
wrappers, because that layer degrades by what the path is (accounting, the
call is already done) rather than by which error type shows up. Narrowing
would have left 4 of 9 wrapper methods as exceptions to a rule nobody can
remember.

No backend raises it from an accounting method today, so this is a
guardrail for whoever adds source-name validation to a breaker backend.

The stub that first reported this green was wrong: its record_success
lacked count_attempt, so it raised TypeError and the wrapper relabeled it.
Fixed signature, then the test failed as it should have.

Also finishes the three-to-five leak path correction across the four
remaining spots, including the wiki summary card that indexes this design.
2026-08-06 06:39:52 -04:00

19 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

任务清单

- [x] 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


- [x] 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


- [x] 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 保持不变 ← 这条是错的,2026-08-06 独立验证时炸出(见 §T6)。正确做法: 该放行必须扩为 except (GovernanceBackendError, SourceNotConfiguredError): raise,否则新增的兄弟类型会落进下一行的 except Exception重新包成 GovernanceBackendError,使 Q1 的拆分在唯一的生产路径上完全失效。
  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 即失败,是回归护栏
闸门泄漏路径(共五条,见设计 §1.1)抛出的异常带正确 scope、且可被 except GatewayUnavailableError 接住;钉住 try_acquire / try_enter / progress_age_s 三条代表路径 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)


- [x] 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


- [x] 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


- [x] T6: 修复独立验证炸出的阻塞缺陷(计划外,2026-08-06)

T1–T5 全绿、全部门禁通过之后,全新上下文的 verifier 用一个QuotaGate端到端用例炸出:装配缺陷在唯一的生产路径上根本没有拆出去。

缺陷: QuotaGate/BreakerGateexcept GovernanceBackendError: raise 只放行了旧类型,新增的 SourceNotConfiguredError 落进下一行 except Exception 被重新包成 GovernanceBackendError(reason=governance_backend_downretry_after_s=5.0)。实证:

RAISED: GovernanceBackendError | isGatewayUnavailable=True | isSourceNotConfigured=False
        | 限流后端故障(source_stats): 未知源 's1'(scope=llm)

即配置写错的任务照样落进"可延期重投"家族,永远重投、永不进死信、无人告警——正是 Q1 要防的镜像 bug,G2 等于没做。

为什么原有测试测不出来: T3 写的两条用例(test_backpressure.pytest_redis_key_layout.py)都直接打私有 _cfg(),绕过了包装器;而治理循环只经包装器访问后端。盲区在于测试打的层次比生产路径低一层。

修复(三处):

文件 改动
middleware/ratelimit.py 4 个方法的放行扩为 except (GovernanceBackendError, SourceNotConfiguredError): raise
middleware/breaker.py 同上,5 个方法
middleware/telemetry.py:254 终态捕获元组加 SourceNotConfiguredError连带坑: 放行生效后该异常不再是 GovernanceBackendError,而它在任何 attempt 之前抛出,若不显式捕获则 emit_terminal_failure 不触发、该路径遥测归零,违反"遥测必录"铁律

回归测试: test_backpressure.py::TestUnknownSourceIsAssemblyDefect::test_survives_the_quota_gate_wrapper(参数化覆盖 try_acquire / stats),走包装器而非私有方法。修前 2 failed,修后 PASS。

同批文档订正: 泄漏路径由"三条"改为五条(遗漏了 QuotaGate.statsBreakerGate.retry_after_s,判据是该调用点是否被 _record_quietly 包裹);CHANGELOG 的 per_source_reasons 表述改为"属性存在但恒为 {}"。

完成后

按 CLAUDE.md §3 Phase 2,合并前须派全新上下文的 verifier subagent 做独立验证(verification-before-completion),并按新规则前台运行。随后走 finishing-a-development-branch 决定合并方式,并在 Gitea 关闭 issue #7。