错误分类:GovernanceBackendError 不在 GatewayUnavailableError 之下,且公开错误面没有文档 #7

Closed
opened 2026-08-05 00:21:05 +08:00 by iomgaa · 1 comment
Owner

调用方(CHSAnalyzer3)要按异常类型把外呼失败分成几类处置,其中一类是「整个用途暂时
不可用 → 延期重投、不消耗业务失败预算」。自然的写法是 except GatewayUnavailableError,
因为它的 docstring 就写着「业务侧 catch 本类做延期重投(CHS arq 模式)」。

按 1.0.1 的源码核了一遍,有两件事想提。

一、GovernanceBackendError 语义上属于 scope 级不可用,类型上却不在 GatewayUnavailableError 之下

它的含义是限流/熔断的状态后端自己坏了,而降级方向是 fail-closed——这时候一个请求都发不
出去,正是「整个 scope 暂时不可用」。但它是 PolyGatewayError 的直接子类。

它确实会到达调用方:记账那条路被 _record_quietly 降级成 warning
middleware/retry.py:404),但闸门那条路(try_enter / try_acquire
middleware/breaker.py:23backends/redis/limiter.py:250)没有这层包裹,会一路抛出去。

于是只写 except GatewayUnavailableError 的调用方会把它落进兜底那一类。对我们这边的
后果很具体:Redis 抖一下,积压的任务一批批消耗失败预算,够到上限就进死信——而那是个
运维重启一下就好的故障。

看得出为什么它现在不是子类GatewayUnavailableError.__init__ 强制要求 scope
reasonretry_after_s 三项,而 reason 必须取自 SCOPE_REASONS,
后端故障不属于其中任何一个,retry_after_s 也没有一个算得出来的值。
所以这不是写错了,是那个构造契约容不下这一种。

两个方向,倾向哪个由你们定:

  • A:给 SCOPE_REASONS 加一个 governance_backend_down,让 GovernanceBackendError
    继承 GatewayUnavailableErrorretry_after_s 取 0——docstring 里已经写了
    「0 = 可立即重试」,语义上是通的。好处是调用方一条 except 就覆盖完整。
  • B(更轻):类型树不动,只在文档里写明这一条也该按「延期重投」处置。

二、看不出哪些异常会到达调用方,哪些是库内吸收的

这一条其实比上一条更值得改,因为它已经让我们写错过一次东西。

TransientErrorSourceDeadError 的 docstring 描述的是治理行为
(「退避后可重试、可换源、计熔断」「不重试,立即换源,该源 force_open」),读起来像是
调用方要处理的东西。实际上 middleware/retry.py:364 那一行
except (SourceDeadError, TransientError) 把它们接住了、return _Failed(...),
重试预算耗尽时统一包成 AllSourcesExhausted——它们根本到不了调用方

我们第一遍只看类型树和 docstring,据此在自己的设计文档里写了一整段「漏接
SourceDeadError 会让一次凭据过期烧掉业务失败预算」,是核到 retry.py 才发现不成立、
整段删掉的。

建议在 errors.py 的模块 docstring 或 README 里加一张两列的表,明确区分:

会到达调用方 库内吸收
GatewayUnavailableError(含 AllSourcesExhaustedCircuitOpenError TransientError
RequestRejectedError SourceDeadError
ResultInvalidError
GovernanceBackendError(仅闸门路径)

这样调用方不必读 middleware/retry.py 才知道自己该 catch 什么。


环境:polygateway 1.0.1(conda 环境 chs,Python 3.13)。
上面每一处行号都是照 src/polygateway/ 当前代码数的。

调用方(CHSAnalyzer3)要按异常类型把外呼失败分成几类处置,其中一类是「整个用途暂时 不可用 → 延期重投、**不消耗**业务失败预算」。自然的写法是 `except GatewayUnavailableError`, 因为它的 docstring 就写着「业务侧 catch 本类做延期重投(CHS arq 模式)」。 按 1.0.1 的源码核了一遍,有两件事想提。 ## 一、`GovernanceBackendError` 语义上属于 scope 级不可用,类型上却不在 `GatewayUnavailableError` 之下 它的含义是限流/熔断的状态后端自己坏了,而降级方向是 fail-closed——这时候一个请求都发不 出去,正是「整个 scope 暂时不可用」。但它是 `PolyGatewayError` 的直接子类。 它确实会到达调用方:记账那条路被 `_record_quietly` 降级成 warning (`middleware/retry.py:404`),但闸门那条路(`try_enter` / `try_acquire`, `middleware/breaker.py:23`、`backends/redis/limiter.py:250`)没有这层包裹,会一路抛出去。 于是只写 `except GatewayUnavailableError` 的调用方会把它落进兜底那一类。对我们这边的 后果很具体:Redis 抖一下,积压的任务一批批消耗失败预算,够到上限就进死信——而那是个 运维重启一下就好的故障。 **看得出为什么它现在不是子类**:`GatewayUnavailableError.__init__` 强制要求 `scope`、 `reason`、`retry_after_s` 三项,而 `reason` 必须取自 `SCOPE_REASONS`, 后端故障不属于其中任何一个,`retry_after_s` 也没有一个算得出来的值。 所以这不是写错了,是那个构造契约容不下这一种。 两个方向,倾向哪个由你们定: - **A**:给 `SCOPE_REASONS` 加一个 `governance_backend_down`,让 `GovernanceBackendError` 继承 `GatewayUnavailableError`,`retry_after_s` 取 0——docstring 里已经写了 「0 = 可立即重试」,语义上是通的。好处是调用方一条 `except` 就覆盖完整。 - **B**(更轻):类型树不动,只在文档里写明这一条也该按「延期重投」处置。 ## 二、看不出哪些异常会到达调用方,哪些是库内吸收的 这一条其实比上一条更值得改,因为它已经让我们写错过一次东西。 `TransientError` 和 `SourceDeadError` 的 docstring 描述的是**治理行为** (「退避后可重试、可换源、计熔断」「不重试,立即换源,该源 force_open」),读起来像是 调用方要处理的东西。实际上 `middleware/retry.py:364` 那一行 `except (SourceDeadError, TransientError)` 把它们接住了、`return _Failed(...)`, 重试预算耗尽时统一包成 `AllSourcesExhausted`——**它们根本到不了调用方**。 我们第一遍只看类型树和 docstring,据此在自己的设计文档里写了一整段「漏接 `SourceDeadError` 会让一次凭据过期烧掉业务失败预算」,是核到 `retry.py` 才发现不成立、 整段删掉的。 建议在 `errors.py` 的模块 docstring 或 README 里加一张两列的表,明确区分: | 会到达调用方 | 库内吸收 | |---|---| | `GatewayUnavailableError`(含 `AllSourcesExhausted`、`CircuitOpenError`) | `TransientError` | | `RequestRejectedError` | `SourceDeadError` | | `ResultInvalidError` | | | `GovernanceBackendError`(仅闸门路径) | | 这样调用方不必读 `middleware/retry.py` 才知道自己该 catch 什么。 --- 环境:polygateway 1.0.1(conda 环境 `chs`,Python 3.13)。 上面每一处行号都是照 `src/polygateway/` 当前代码数的。
Author
Owner

已在 1.1.0 落地并合入 main(9c2824c),关闭。两条诉求都做了,方向按你给的 A(类型树归位)而非 B(只补文档)。

一、GovernanceBackendError 归位

4507348 改为继承 GatewayUnavailableError,reason 恒为新增的 governance_backend_down。你写的「A 与 B 倾向哪个由你们定」——选 A 的理由是 B 的正确性依赖每个下游都读到那句话,而本 issue 本身就是"文档读不出来"引发的,同一失效模式不能用同一种药治。

retry_after_s 没取 0,取了常量 5.0。 你说的「docstring 里已经写了 0 = 可立即重试,语义上是通的」语义确实通,但工程上不通: Redis 挂掉时积压任务会以零延迟批量重投,对着一个已经挂掉的后端打忙循环,把一次故障放大成一场风暴。常量定义在 errors.pyGOVERNANCE_BACKEND_RETRY_AFTER_S,不是环境配置项(后端恢复时间物理上不可知,不同于熔断冷却有确定到期时刻);你们有自己的退避策略可以忽略这个字段。

你的泄漏路径清单少了三条。 你列了 try_enter / try_acquire,实际是五条: QuotaGatetry_acquire / stats / progress_age_s,BreakerGatetry_enter / retry_after_s。判据是该 gate 调用点是否被 _record_quietly 包裹——未包裹即直达调用方。五条现在都携带 scope / reason / retry_after_s;str(exc) 仍是原诊断串(如 限流后端 try_acquire 失败: ...),结构化字段与诊断信息并存。

对 CHSAnalyzer3 的直接影响: tracking.py 一条 except GatewayUnavailableError 即覆盖完整,无需为后端故障单列分支。你们现有的 except GovernanceBackendError(若有)继续有效——加父类是扩大捕获面,不是破坏。

二、你没提但必须一起处理的一件事

限流后端 _cfg() 在源名不在配置字典中时GovernanceBackendError(redis/limiter.py:198memory/limiter.py:92)。那不是后端故障,是装配缺陷。若随整类归入"可延期重投",配置写错的任务会永远重投、永不进死信、无人告警——正是本 issue 要修的 bug 的镜像。

故新增 SourceNotConfiguredError(公共导出),有意不在 GatewayUnavailableError 之下。你们那边它会落进 _TERMINAL 分支消耗失败预算进死信,这是刻意的: 配置写错就该让人看见。

三、公开错误面文档(你说这条比第一条更值得改)

README 新增两列表,明确 TransientError / SourceDeadErrormiddleware/retry.py:365 接住、耗尽时统一包成 AllSourcesExhausted,根本到不了调用方——你们据此写错整段设计文档那件事,现在读 README 就能避免,不必去读 middleware/retry.pyARCHITECTURE.md §6.1 也回补了这两个类(该类自 M2 引入分布式后端起就没进过那张表,这正是 README 遗漏的根因)。

四、独立验证抓到的两个缺陷(都已修,值得你们知道)

  1. a57a5ce: 首版实现里 gate 包装器的 except GovernanceBackendError: raise 只放行旧类型,SourceNotConfiguredError 落进下一行 except Exception重新包回 GovernanceBackendError——第二条在唯一的生产路径上等于没做。原测试没抓到,是因为它们直接打私有 _cfg(),比生产路径低一层。
  2. 5853c3f: 上条的修复让该异常能穿透 _record_quietly,而那层降级的存在理由是"调用已真实完成,写回失败不该丢弃成功响应"。同批放宽了三处 _record_quietly

升级注意

处置路线会变: 后端故障从"落进兜底分支、按业务失败处置"变为"按 scope 级不可用延期重投、不消耗失败预算"。这正是修复目标,但升级前请确认你们的兜底分支没有依赖旧行为(例如靠它触发告警)。完整说明见 CHANGELOG 1.1.0。

安装: pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ "polygateway[redis,postgres,structured]==1.1.*"

已在 1.1.0 落地并合入 main(`9c2824c`),关闭。两条诉求都做了,方向按你给的 **A**(类型树归位)而非 B(只补文档)。 ## 一、`GovernanceBackendError` 归位 `4507348` 改为继承 `GatewayUnavailableError`,`reason` 恒为新增的 `governance_backend_down`。你写的「A 与 B 倾向哪个由你们定」——选 A 的理由是 B 的正确性依赖每个下游都读到那句话,而本 issue 本身就是"文档读不出来"引发的,同一失效模式不能用同一种药治。 **`retry_after_s` 没取 0,取了常量 5.0。** 你说的「docstring 里已经写了 0 = 可立即重试,语义上是通的」语义确实通,但工程上不通: Redis 挂掉时积压任务会以零延迟批量重投,对着一个已经挂掉的后端打忙循环,把一次故障放大成一场风暴。常量定义在 `errors.py` 的 `GOVERNANCE_BACKEND_RETRY_AFTER_S`,**不是环境配置项**(后端恢复时间物理上不可知,不同于熔断冷却有确定到期时刻);你们有自己的退避策略可以忽略这个字段。 **你的泄漏路径清单少了三条。** 你列了 `try_enter` / `try_acquire`,实际是**五条**: `QuotaGate` 的 `try_acquire` / `stats` / `progress_age_s`,`BreakerGate` 的 `try_enter` / `retry_after_s`。判据是该 gate 调用点是否被 `_record_quietly` 包裹——未包裹即直达调用方。五条现在都携带 `scope` / `reason` / `retry_after_s`;`str(exc)` 仍是原诊断串(如 `限流后端 try_acquire 失败: ...`),结构化字段与诊断信息并存。 **对 CHSAnalyzer3 的直接影响**: `tracking.py` 一条 `except GatewayUnavailableError` 即覆盖完整,**无需为后端故障单列分支**。你们现有的 `except GovernanceBackendError`(若有)继续有效——加父类是扩大捕获面,不是破坏。 ## 二、你没提但必须一起处理的一件事 限流后端 `_cfg()` 在源名不在配置字典中时**也**抛 `GovernanceBackendError`(`redis/limiter.py:198`、`memory/limiter.py:92`)。那不是后端故障,是装配缺陷。若随整类归入"可延期重投",配置写错的任务会**永远重投、永不进死信、无人告警**——正是本 issue 要修的 bug 的镜像。 故新增 `SourceNotConfiguredError`(公共导出),**有意不在** `GatewayUnavailableError` 之下。你们那边它会落进 `_TERMINAL` 分支消耗失败预算进死信,这是刻意的: 配置写错就该让人看见。 ## 三、公开错误面文档(你说这条比第一条更值得改) README 新增两列表,明确 `TransientError` / `SourceDeadError` 被 `middleware/retry.py:365` 接住、耗尽时统一包成 `AllSourcesExhausted`,**根本到不了调用方**——你们据此写错整段设计文档那件事,现在读 README 就能避免,不必去读 `middleware/retry.py`。`ARCHITECTURE.md` §6.1 也回补了这两个类(该类自 M2 引入分布式后端起就没进过那张表,这正是 README 遗漏的根因)。 ## 四、独立验证抓到的两个缺陷(都已修,值得你们知道) 1. `a57a5ce`: 首版实现里 gate 包装器的 `except GovernanceBackendError: raise` 只放行旧类型,`SourceNotConfiguredError` 落进下一行 `except Exception` 被**重新包回** `GovernanceBackendError`——第二条在唯一的生产路径上等于没做。原测试没抓到,是因为它们直接打私有 `_cfg()`,比生产路径低一层。 2. `5853c3f`: 上条的修复让该异常能穿透 `_record_quietly`,而那层降级的存在理由是"调用已真实完成,写回失败不该丢弃成功响应"。同批放宽了三处 `_record_quietly`。 ## 升级注意 **处置路线会变**: 后端故障从"落进兜底分支、按业务失败处置"变为"按 scope 级不可用延期重投、不消耗失败预算"。这正是修复目标,但升级前请确认你们的兜底分支没有依赖旧行为(例如靠它触发告警)。完整说明见 CHANGELOG 1.1.0。 安装: `pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ "polygateway[redis,postgres,structured]==1.1.*"`
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#7