Files
PolyGateway/research-wiki/designs/2026-08-06-governance-backend-error-design.md
T
iomgaa 2f5abb6a55 docs: design the governance backend error reclassification (issue #7)
Fail-closed governance backend failures are semantically scope-level
unavailability, yet GovernanceBackendError sits directly under
PolyGatewayError, so callers writing only `except GatewayUnavailableError`
drop them into the catch-all bucket and burn their failure budget on a
fault that a restart would clear.

The design reparents it under GatewayUnavailableError with a new
governance_backend_down reason, splits the two "unknown source" sites into
a separate assembly-defect error so a misconfiguration still reaches the
dead letter queue, and picks a non-zero retry_after_s to avoid a
zero-delay retry storm against a backend that is already down.
2026-08-06 02:33:06 -04:00

13 KiB
Raw Blame History

治理后端故障归位为 scope 级不可用设计(Issue #7)

  • 日期: 2026-08-06
  • 来源: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败,基于 1.0.1 源码核查)
  • 状态: 待人类审批
  • 触发档位: 强制(变更 errors.py 公共错误类型树 = 库对下游的承诺)
  • 方案范围: 人类已选定方向 A′ 并明确要求单一方案,故本文不列平行备选,仅在 §4 记录被否决路线及否决理由

1. 目标与非目标

内容
G1 GovernanceBackendError 归入 GatewayUnavailableError 之下,使"该延期重投的失败"在类型上闭合——调用方一条 except GatewayUnavailableError 覆盖完整,漏接在物理上不可能
G2 把混在同一类里的装配期缺陷("未知源")拆出去,使其被误判为可重投
G3 retry_after_s 取非零值,避免后端故障期间下游零延迟批量重投形成忙循环
G4 公开错误面文档化:README 增"会到达调用方 / 库内吸收"两列表,ARCHITECTURE.md §6.1 回补缺失的 GovernanceBackendError
非目标 不改 fail-closed 降级方向(限流/熔断后端不可用 → 报错而非放行,库铁律不动);不改后端重连/健康探测;不新增配置项;不改 TransientError/SourceDeadError 的库内吸收行为

1.1 Issue 前提的四处修正(按 1.0.6 源码核实)

Issue 原文 实际情况
泄漏路径为 try_enter / try_acquire 两条 三条middleware/retry.py:216 每轮循环开头的 progress_age_s() 同样在 catch 之外,直达调用方
(未提及构造点数量) 全库 22 处 raise GovernanceBackendError,分布于 4 个文件
方向 A 只需改类型树 其中 2 处语义完全不同(见 §3.4),整类归入"可重投"会制造镜像 bug
retry_after_s 取 0,「docstring 已写 0 = 可立即重试,语义上是通的」 语义通,工程上不通。见 §3.2

另需记录一处根因:ARCHITECTURE.md:372-378 §6.1 的错误分类表里 GovernanceBackendError 一次都没出现。它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来就没有位置——README 的遗漏是这个遗漏的下游后果。

2. 影响面的决定性前提(改动安全性的依据)

事实 证据 含义
库内仅一处 except GatewayUnavailableError middleware/telemetry.py:210,写法为 except (GatewayUnavailableError, GovernanceBackendError) 变成父子关系后该处由"并列捕获"退化为"父类捕获",行为逐字不变,库内零回归
加父类是纯扩大 下游既有 except GovernanceBackendError 全部照旧命中 不违反 CLAUDE.md §4.3「已被下游消费的公共类型只增不删不改名」
QuotaGate/BreakerGate 是后端异常的唯一入口 两类 docstring 自述,三处装配 retry.py:186 / ocr.py:122 / embedding.py:123 scope 注入点收敛为 2 个类、3 处装配
三个装配点都持有 self._scope retry.py:183ocr.py:116embedding.py:118 注入无需新增上游参数传递链

3. 选定方案

3.1 类型树变更

SCOPE_REASONS 增枚举值 governance_backend_down;GovernanceBackendError 改继承 GatewayUnavailableError,reason 恒为该值(与 CircuitOpenError 恒为 circuit_open 同构,是本库已有的表达手法)。

构造签名保持"首参为 message"的位置参数形态,以免 22 处构造点与既有测试全部改写:

class GovernanceBackendError(GatewayUnavailableError):
    def __init__(self, message, *, scope, retry_after_s=GOVERNANCE_BACKEND_RETRY_AFTER_S,
                 source_name=None):
        super().__init__(scope=scope, reason="governance_backend_down",
                         retry_after_s=retry_after_s, source_name=source_name)
        self.args = (message,)   # 见 §3.5

scope 为必填 keyword(P4 显式优于隐式:它在三层调用点全部可得,给默认值只会掩盖装配疏漏)。

3.2 retry_after_s 的取值(本设计的核心权衡)

Issue 建议取 0。否决:下游 schedule_retry(after_s=0) 会立刻重投,Redis 挂掉期间队列里积压的任务将以零延迟批量重投,对着一个已经挂掉的后端打忙循环——把一次故障放大成一场风暴。这与本 issue 想修的问题同源:都是"分类正确但处置参数错误"。

已考虑并否决的两个替代取值:

取值 否决理由
复用 BackpressureConfig.poll_interval_s(与 quota_exhausted 同源,retry.py:299 有先例) 该值只有三个装配点持有,后端层 11 处构造点拿不到;为此给 RedisLimiter/RedisBreaker 增构造参数,是让后端层去持有"重投策略"——违反 P7(决策逻辑与状态存储分离),后端只该知道"我坏了",不该知道这在治理上意味着什么
新增配置项 PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S YAGNI。目前无任何下游表达过需要调它;真需要时下游可完全忽略 exc.retry_after_s 用自有退避

选定:errors.py 模块级常量 GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0,作为构造默认值,docstring 写明理由——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),取一个保守固定值;下游若有自己的退避策略可忽略此值。本库对 scope 级异常硬编码语义值已有先例(retry.py:206no_sources0.0)。

3.3 scope 的三层来源

构造点数 scope 来源 改动
backends/redis/limiter.py 6 self._scope(:170) scope=self._scope
backends/redis/breaker.py 5 self._scope(:291) scope=self._scope
middleware/breaker.py BreakerGate 5 需注入 构造函数增 scope: str,三处装配传 self._scope
middleware/ratelimit.py QuotaGate 4 需注入 同上

包装器对后端自抛异常的 except GovernanceBackendError: raise 原样放行保持不变——后端层已填好 scope,重建实例只会制造"同一异常构造两次"的怪味且覆盖值相同。

3.4 "未知源"拆分为独立错误类

backends/memory/limiter.py:92backends/redis/limiter.py:198_cfg() 在源名不在配置字典中时抛 GovernanceBackendError这不是后端故障,是限流后端拿到的源列表与治理循环的对不上——装配期缺陷,正常不可达。

若随整类归入"延期重投、不扣失败预算",配置写错的任务将永远重投、永远不进死信,运维永远收不到告警——正是本 issue 要修的 bug 的镜像。

新增 SourceNotConfiguredError(PolyGatewayError),有意不放在 GatewayUnavailableError 之下:下游默认按"任务的错"处置 → 扣失败预算 → 进死信 → 人能看见。这是缺陷该有的可见性。该类进 __init__.py 公共导出(下游可选择性识别,但不识别也能得到正确处置)。

3.5 message 保全

GatewayUnavailableError.__init__ 会把 message 覆盖为 f"{scope} 网关暂时不可用: {reason}",而 22 处构造点携带的诊断串(如 限流后端 try_acquire 失败: {exc})是排障的主要线索,不可丢。方案是 super().__init__() 后覆写 self.args = (message,),使 str(exc) 仍为原诊断串,而 scope/reason/retry_after_s 作为结构化字段并存。父类不动——它的 message 生成逻辑对 CircuitOpenError/AllSourcesExhausted 仍然正确。

4. 被否决的路线

路线 否决理由
B: 只补文档,类型树不动 正确性依赖每个下游都读到那句话。本库下游不止一个,且本 issue 本身就是"文档读不出来"引发的——同一个失效模式不能用同一种药治
C: 类型树不动,在 RetryMW 边界包成 AllSourcesExhausted 比 A 更具破坏性:下游现有 except GovernanceBackendError 会直接失效。加父类是扩大,换类型是破坏
D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译(初评时倾向,已否决) backends/redis/limiter.py:133,151RedisPermit.release/settle 依赖 except GovernanceBackendError 实现释放侧降级(失败只 warning 不冒泡)。原始 redis 异常穿透后该处接不住,会破坏这条既有降级行为;改为 except Exception 则违反 P5

5. 行为审计(既有行为逐条标注)

既有行为 出处 处置
限流/熔断后端不可用 → 报错而非放行(fail-closed) 库铁律 保留,一字不改
记账路径后端故障降级为 warning middleware/retry.py:404 _record_quietly 保留。仅闸门路径需要到达调用方
permit release/settle 失败降级 warning redis/limiter.py:133,151 保留(§4 路线 D 因此被否决)
遥测对后端故障发 emit_terminal_failure middleware/telemetry.py:210 保留,父子关系后由父类分支承接,行为不变
except GovernanceBackendError: raise 原样放行 包装器 9 处 保留
"未知源"抛 GovernanceBackendError memory/limiter.py:92redis/limiter.py:198 替换SourceNotConfiguredError(§3.4)
str(exc) 为诊断串 22 处 保留(§3.5 显式保全)

6. 非功能维度

维度 回答
并发与取消 不适用于新增并发路径。异常构造是纯同步无状态操作,不引入共享状态。CancelledError 穿透路径完全不受影响——本设计不新增任何 except 子句,_record_quietlyexcept asyncio.CancelledError(:402)先于 except GovernanceBackendError(:404)的顺序不动
降级方向 不变。fail-closed 是本类存在的理由,本设计只改"它被归入哪一类",不改"它是否被抛出"
幂等与重复 异常类型变更不涉及幂等性。需注意的是下游行为改变:同一次后端故障从"扣失败预算"变为"延期重投",重投次数由下游队列策略决定——这正是期望的变更,已在 CHANGELOG 行为变更段声明
持久化与原子性 无持久化改动。遥测落库路径(emit_terminal_failure)的字段与调用时机均不变

7. 错误处理与测试策略

新失败面只有一个:SourceNotConfiguredError,落在四分类之外(它是装配缺陷而非调用失败),这是有意的——四分类描述"一次调用如何失败",装配缺陷不属于该论域。

测试 位置 先失败后通过的证据
GovernanceBackendError 可被 except GatewayUnavailableError 接住 tests/unit/test_errors.py 改前 pytest.raises(GatewayUnavailableError) 必失败
三条泄漏路径(try_acquire/try_enter/progress_age_s)抛出的异常携带正确 scope 与非零 retry_after_s tests/unit/test_backpressure.py(已有 :176-186 的注入桩可复用) 改前无 scope 属性,AttributeError
str(exc) 仍为原诊断串 tests/unit/test_errors.py 防 §3.5 回归
未知源抛 SourceNotConfiguredError不是 GatewayUnavailableError tests/unit/test_redis_key_layout.py:71-73、内存版对应用例 改前抛 GovernanceBackendError,断言"不是 scope 级"必失败
Redis 真实掉线时准入侧行为 tests/integration/test_redis_cross_connection.py:228-245(真实 Redis,不 mock) 断言由 GovernanceBackendError 收紧为"是 GatewayUnavailableErrorreason == governance_backend_down"

8. 影响面清单

类别 内容
源码 errors.py(新常量+新类+继承变更)、backends/redis/limiter.py(7)、backends/redis/breaker.py(5)、backends/memory/limiter.py(1)、middleware/breaker.py(6:构造函数+5 处)、middleware/ratelimit.py(5)、middleware/retry.py/ocr.py/embedding.py(各 1 行装配)、__init__.py(导出新类)
测试 tests/unit/test_errors.pytest_backpressure.pytest_redis_key_layout.pytests/integration/test_redis_cross_connection.py
文档 README.md §"错误模型"增两列表 + GovernanceBackendError 行;ARCHITECTURE.md §6.1 回补该类并记录本次归位;migrations/chsanalyzer.md G1 条目补注;CHANGELOG.md 1.1.0;按 docs-convention.md §2 同步 Gitea Wiki
版本 1.1.0。有行为变更(下游对后端故障的处置路线改变)但无 API 破坏(加父类是扩大),按语义化版本走 minor
下游 CHSAnalyzer3 当前在 1.0.1。升级后 except GatewayUnavailableError 即覆盖后端故障,其现有 except GovernanceBackendError(若有)继续有效,无需改代码即可获得修复

9. 待人类确认的决策点

# 决策 本文的选择 若你倾向不同
D1 "未知源"归到哪 拆为 SourceNotConfiguredError,GatewayUnavailableError 之下(§3.4) 若认为它该沿用 GovernanceBackendError,则 §3.4 整节作废,但需接受"配置写错永不进死信"
D2 retry_after_s 取值 常量 5.0(§3.2) 可改为配置项或其他常量值;不建议取 0
D3 新类是否公共导出 是(进 __init__.py) 若只作内部诊断可不导出

10. 审批记录

阶段 状态
Claude 自审 已完成(全部结论对应本会话内 grep/read 输出)
Codex 独立审 待执行
人类审批 待执行(强制档:变更 errors.py 公共错误类型树)