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.
13 KiB
治理后端故障归位为 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:183、ocr.py:116、embedding.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:206 的 no_sources 取 0.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:92 与 backends/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,151 的 RedisPermit.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:92、redis/limiter.py:198 |
替换为 SourceNotConfiguredError(§3.4) |
str(exc) 为诊断串 |
22 处 | 保留(§3.5 显式保全) |
6. 非功能维度
| 维度 | 回答 |
|---|---|
| 并发与取消 | 不适用于新增并发路径。异常构造是纯同步无状态操作,不引入共享状态。CancelledError 穿透路径完全不受影响——本设计不新增任何 except 子句,_record_quietly 中 except 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 收紧为"是 GatewayUnavailableError 且 reason == 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.py、test_backpressure.py、test_redis_key_layout.py、tests/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 公共错误类型树) |