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

160 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 治理后端故障归位为 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 处构造点与既有测试全部改写:
```python
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` 公共错误类型树) |