Files
PolyGateway/research-wiki/designs/2026-08-06-governance-backend-error-design.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

17 KiB
Raw Blame History

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

  • 日期: 2026-08-06
  • 来源: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败,基于 1.0.1 源码核查)
  • 状态: 已批准(2026-08-06),待 writing-plans
  • 触发档位: 强制(变更 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:250,写法为 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)。

不是环境配置项,故不落 CLAUDE.md §4.5「严禁硬编码默认值」的论域——§4.5 约束的是 pydantic-settings + .env 管辖的工程配置(超时、并发、限额),而本常量是异常自身携带的语义默认值,与 no_sources0.0 同性质。docstring 需显式写明这一点,避免后来者误加环境键。

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:250 保留,父子关系后由父类分支承接,行为不变
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,它落在四分类之外。这不违反 CLAUDE.md §4.2「一切失败必须落入四分类」——该铁律的论域是 transport 层翻译的调用失败(ARCHITECTURE.md §6.2 的翻译规则表逐条对应 HTTP 状态码与解析失败),而本库已有一整族异常合法地处在四分类之外:GatewayUnavailableError / CircuitOpenError / AllSourcesExhausted 都不是四分类之一,ARCHITECTURE.md §6.1 把它们单列一行,因为它们回答的是另一个问题——"整个 scope 还能不能用",而非"这一次调用怎么失败的"。

SourceNotConfiguredError 属于第三个论域:装配缺陷(配置与治理循环不一致,正常不可达)。四分类决定重试/换源/熔断,而装配缺陷根本不该进入治理循环去被"决定",它应当立刻失败并让人看见。将其塞进四分类中的任何一类都会赋予它一份不该有的治理语义(如 RequestRejectedError 会让下游以为请求本身有问题、去修请求)。§9 Q1 保留了"复用 RequestRejectedError"作为备选供人类权衡。

测试 位置 先失败后通过的证据
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三条桩都需新增(Codex 审计划时核出: :176-186 是记账侧 record_success/record_failure/mark_progress 的降级桩,不是闸门路径;progress_age_s:243-257 覆盖包装行为、不验 scope) 改前无 scope 属性,AttributeError
str(exc) 仍为原诊断串 tests/unit/test_errors.py 防 §3.5 回归
未知源抛 SourceNotConfiguredError不是 GatewayUnavailableError tests/unit/test_redis_key_layout.py:70-74;内存版当前无覆盖,需新增 改前抛 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(若有)继续有效,无需改代码即可获得修复

8.1 执行顺序(单一事实源纪律)

ARCHITECTURE.md 是架构单一事实源,SCOPE_REASONS 新增值域与 GovernanceBackendError 的归位都与其 §6.1 现状冲突。因此 §6.1 的修订必须先于或同批于代码实现落地,不得"先改代码、事后补文档"。具体为:人类批准本设计后,writing-plans 的第一项任务即为修订 ARCHITECTURE.md §6.1(补 GovernanceBackendErrorSourceNotConfiguredError 行、scope 级 reason 值域增 governance_backend_down、记录本次归位的理由与日期),与实现同一分支、同批提交。

9. 待人类确认的决策点

(编号用 Q 前缀,避免与 ARCHITECTURE.md 的架构决策 D1D14 混淆)

三点均已由人类拍板(2026-08-06),全部采纳本文的选择:

# 决策 裁定 被否决的备选及理由
Q1 "未知源"归到哪 拆为 SourceNotConfiguredError,不在 GatewayUnavailableError 之下(§3.4) ① 沿用 GovernanceBackendError——配置写错的任务将无限重投、永不进死信、无人发现;② 复用 RequestRejectedError——治理行为与选定方案完全等价,但名称误导:下游会去查 prompt 而非配置文件
Q2 retry_after_s 取值 常量 5.0(§3.2) 取 0 会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成风暴
Q3 新类是否公共导出 导出(进 __init__.py) 不导出则下游无法给"配置写错"单独接告警,而导出无成本

10. 审批记录

阶段 状态
Claude 自审 已完成(全部结论对应本会话内 grep/read 输出;§3.5 的 self.args 保全机制经 conda 环境实跑验证)
Codex 独立审 已完成(2026-08-06),4 条意见逐条核验见下
人类审批 已批准(2026-08-06)。方向 A′ 于设计前即由人类选定;Q1–Q3 三个决策点逐条拍板,全部采纳本文选择(见 §9)。可进入 writing-plans

10.1 Codex 意见的核验结果

意见 判定 处置
ARCHITECTURE §6.1 未同步前实施违反单一事实源(判为阻塞) 实质成立,但性质是执行顺序而非设计缺陷——§8 本已把 §6.1 回补列入影响面 新增 §8.1 明确"架构文档修订先于/同批于实现"
§6.1 错误分类表未承认 GovernanceBackendError(判为阻塞) 与上条同源,且 §1.1 已自陈此为根因 同上,由 §8.1 覆盖
SourceNotConfiguredError 落在四分类外违反 §4.2 铁律(判为阻塞) 部分成立:铁律论域被误读——GatewayUnavailableError 族本就合法处在四分类之外(§6.1 单列一行)。但原文表述确会引起该疑虑 §7 补写三个论域的划分论证;§9 Q1 增列"复用 RequestRejectedError"备选交人类权衡
硬编码常量与 §4.5 存在张力(建议性) 成立 §3.2 补写"非环境配置项"及 docstring 要求
Q 编号与架构 D1–D14 混淆(建议性) 成立 §9 决策点编号由 D 改为 Q