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.
17 KiB
治理后端故障归位为 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: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)。
它不是环境配置项,故不落 CLAUDE.md §4.5「严禁硬编码默认值」的论域——§4.5 约束的是 pydantic-settings + .env 管辖的工程配置(超时、并发、限额),而本常量是异常自身携带的语义默认值,与 no_sources 取 0.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: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:250 |
保留,父子关系后由父类分支承接,行为不变 |
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,它落在四分类之外。这不违反 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 收紧为"是 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(若有)继续有效,无需改代码即可获得修复 |
8.1 执行顺序(单一事实源纪律)
ARCHITECTURE.md 是架构单一事实源,SCOPE_REASONS 新增值域与 GovernanceBackendError 的归位都与其 §6.1 现状冲突。因此 §6.1 的修订必须先于或同批于代码实现落地,不得"先改代码、事后补文档"。具体为:人类批准本设计后,writing-plans 的第一项任务即为修订 ARCHITECTURE.md §6.1(补 GovernanceBackendError 与 SourceNotConfiguredError 行、scope 级 reason 值域增 governance_backend_down、记录本次归位的理由与日期),与实现同一分支、同批提交。
9. 待人类确认的决策点
(编号用 Q 前缀,避免与 ARCHITECTURE.md 的架构决策 D1–D14 混淆)
三点均已由人类拍板(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 |