Independent verification caught that the split shipped in the previous commit did not actually hold on the only path production uses. The gate wrappers re-raise GovernanceBackendError but nothing else, so SourceNotConfiguredError fell into the following `except Exception` and came back out as a governance_backend_down failure with retry_after_s=5.0. A misconfigured source name would still retry forever and never surface. The existing tests missed it because both of them call the private _cfg() directly, one layer below the wrapper the governance loops actually go through. The regression test goes through QuotaGate. telemetry.py has to widen its terminal catch in the same commit: once the wrapper stops relabeling the error, it is no longer a GovernanceBackendError, and it is raised before any attempt exists, so the path would have recorded no telemetry at all. Also corrects the leak path count from three to five. QuotaGate.stats and BreakerGate.retry_after_s are not wrapped by _record_quietly either.
19 KiB
实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
- 设计:
research-wiki/designs/2026-08-06-governance-backend-error-design.md(已批准 2026-08-06,Q1/Q2/Q3 逐条拍板) - 分支:
feat/issue-7-governance-backend-error - 目标: 让"限流/熔断后端故障"在类型上落入
GatewayUnavailableError,使调用方一条except覆盖完整;同时把混在同一类里的装配缺陷拆出去,避免配置写错的任务永远重投。 - 方案概述:
GovernanceBackendError改继承GatewayUnavailableError(新 reasongovernance_backend_down,retry_after_s默认 5.0);两处"未知源"改抛新增的SourceNotConfiguredError(不在 scope 级家族内);scope由后端层self._scope与两个 gate 包装器注入。 - 涉及技术: Python 3.11+,pytest(含真实 Redis 的 integration),radon/ruff 门禁。
保真校验(适用)
本计划触及 ARCHITECTURE.md §1.4 索引的移植蓝本:错误分类(reference/CHSAnalyzer/app/domain/errors.py)与限流/熔断(reference/CHSAnalyzer/app/coordination/)。
本次有意变更的语义只有一条,已在设计 §3.1 声明:GovernanceBackendError 的类型归属(CHS 的 LimiterError 是独立异常,本库将其提升为 scope 级不可用的一员)。除此之外,下列承自 CHS 的语义不得被顺带改动,每个任务完成前逐条自查:
| 不得改动 | 出处 |
|---|---|
retry_after_s 非可选、0 = 可立即重试 |
errors.py:74-78 |
SCOPE_REASONS 既有 5 值与 SOURCE_REASONS 既有 7 值 |
errors.py:7-20 |
| fail-closed 降级方向(限流/熔断后端挂 → 报错而非放行) | 库铁律 |
| 记账路径降级为 warning、闸门路径上抛的分工 | middleware/retry.py:404 |
RedisPermit.release/settle 的释放侧降级 |
backends/redis/limiter.py:133,151 |
文件结构
| 文件 | 职责 | 动作 |
|---|---|---|
research-wiki/ARCHITECTURE.md |
架构单一事实源 §6.1 错误分类表 | 修改(必须先行,见设计 §8.1) |
src/polygateway/errors.py |
错误类型树内核 | 修改: 新常量、新 reason、新类、继承变更 |
src/polygateway/__init__.py |
公共 API 面 | 修改: 导出新类 |
src/polygateway/backends/redis/limiter.py |
Redis 限流后端 | 修改: 6 处补 scope、1 处换新类 |
src/polygateway/backends/redis/breaker.py |
Redis 熔断后端 | 修改: 5 处补 scope |
src/polygateway/backends/memory/limiter.py |
内存限流后端 | 修改: 1 处换新类 |
src/polygateway/middleware/ratelimit.py |
QuotaGate 包装器 |
修改: 构造增 scope、4 处补 scope |
src/polygateway/middleware/breaker.py |
BreakerGate 包装器 |
修改: 构造增 scope、5 处补 scope |
src/polygateway/middleware/retry.py / ocr.py / embedding.py |
三处 gate 装配 | 修改: 各 2 行传 scope |
tests/unit/test_errors.py |
错误类型契约 | 修改 |
tests/unit/test_backpressure.py |
后端故障传播 | 修改 |
tests/unit/test_redis_key_layout.py |
未知源行为 | 修改 |
tests/integration/test_redis_cross_connection.py |
真实 Redis 掉线 | 修改 |
README.md / research-wiki/migrations/chsanalyzer.md / CHANGELOG.md / pyproject.toml |
文档与版本 | 修改 |
关键接口(跨任务消费,此处写死)
errors.py 新增与变更部分:
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
"""治理后端故障的建议重投间隔(秒)。
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期
时刻),故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让
积压任务零延迟同时冲击已挂掉的后端(issue #7 §3.2)。
"""
class SourceNotConfiguredError(PolyGatewayError):
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是
"配置写错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置
错误的任务永远重投、永不告警(issue #7 §3.4)。
"""
class GovernanceBackendError(GatewayUnavailableError):
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
继承 `GatewayUnavailableError`: fail-closed 时一个请求都发不出去,语义
上即 scope 级不可用,调用方一条 except 即可覆盖(issue #7)。
"""
def __init__(
self,
message: str,
*,
scope: str,
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
source_name: str | None = None,
) -> None:
super().__init__(
scope=scope,
reason="governance_backend_down",
retry_after_s=retry_after_s,
source_name=source_name,
)
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
# 携带的诊断串是排障主线索,必须保住(设计 §3.5,机制已实跑验证)
self.args = (message,)
两个 gate 包装器的构造签名(scope 为 keyword-only 必填):
class QuotaGate:
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
self._limiter = limiter
self._scope = scope
class BreakerGate:
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
self._gate = gate
self._scope = scope
任务清单
- [x] T1: ARCHITECTURE §6.1 回补(必须先行)
文件: research-wiki/ARCHITECTURE.md(§6.1,约 372-380 行)
行为: 在错误分类表补两行——GovernanceBackendError(scope 级不可用,reason 恒为 governance_backend_down)与 SourceNotConfiguredError(装配缺陷,不重试不换源,消耗失败预算);scope 级 reason 值域由 5 值扩为 6 值,增 governance_backend_down。同时记录本次归位的理由与日期,并说明根因(该类是 M2 引入分布式后端时新增,当时未回补本表)。
为什么先行: ARCHITECTURE.md 是单一事实源,新 reason 值域与其现状冲突;先改代码后补文档等于让实现与事实源脱节(设计 §8.1)。
验收: §6.1 表格含上述两行;reason 值域文字与 errors.py 将要写入的 SCOPE_REASONS 逐字一致。
测试要求: 纯文档,无测试证据要求。
验证: grep -n "governance_backend_down\|SourceNotConfiguredError" research-wiki/ARCHITECTURE.md → 至少各 1 处命中。
提交: docs: admit governance backend failures into the scope-level error model
- [x] T2: errors.py 纯增量(新常量、新 reason、新类)+ 导出
文件: 改 src/polygateway/errors.py、src/polygateway/__init__.py;改 tests/unit/test_errors.py
行为:
- 加模块级常量
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0(docstring 逐字见上文"关键接口"); SCOPE_REASONS增"governance_backend_down";- 新增
SourceNotConfiguredError(PolyGatewayError)(定义逐字见上文); __init__.py的 import 块与__all__各增SourceNotConfiguredError(__all__保持字母序:SourceDeadError→SourceNotConfiguredError→TransientError,即插在SourceDeadError之后)。
本任务不动 GovernanceBackendError——它是纯增量,不破坏任何既有调用点,可独立提交且全套件保持通过。
测试要求(先失败后通过):
- 新增用例断言
SourceNotConfiguredError不是GatewayUnavailableError的子类,且是PolyGatewayError的子类。改前该类不存在 →ImportError;改后 PASS。 - 新增用例断言
"governance_backend_down" in SCOPE_REASONS,且GatewayUnavailableError(scope="llm", reason="governance_backend_down", retry_after_s=0.0)可构造。改前reason校验抛ValueError→ 用例失败;改后 PASS。 - 新增用例断言
from polygateway import SourceNotConfiguredError可用。
验证: conda run -n PolyGateway pytest tests/unit/test_errors.py -v → 全 PASS;conda run -n PolyGateway pytest tests/ -q → 与改动前同样全绿(纯增量不应影响任何既有用例)。
提交: feat: add SourceNotConfiguredError and the governance backend reason
- [x] T3: GovernanceBackendError 归位 + 22 处构造点 + scope 注入(原子)
文件: 改 src/polygateway/errors.py、backends/redis/limiter.py、backends/redis/breaker.py、backends/memory/limiter.py、middleware/ratelimit.py、middleware/breaker.py、middleware/retry.py、ocr.py、embedding.py;改 tests/unit/test_errors.py、tests/unit/test_backpressure.py、tests/unit/test_redis_key_layout.py、tests/integration/test_redis_cross_connection.py
为什么必须原子: scope 是必填 keyword,继承变更与全部构造点若分批提交,中间状态会 TypeError,门禁跑不过。
行为:
-
errors.py:GovernanceBackendError改继承GatewayUnavailableError并覆写__init__(逐字见上文"关键接口")。 -
两处未知源改抛新类(设计 §3.4,Q1 已拍板):
| 位置 | 改为 |
|---|---|
backends/redis/limiter.py:198 |
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})") |
backends/memory/limiter.py:92 |
同上 |
-
后端层 11 处补
scope=self._scope(该属性已存在: redis limiter:170、redis breaker:291、memory limiter 同名字段):backends/redis/limiter.py的:250 / :268 / :275 / :286 / :298 / :305(6 处)backends/redis/breaker.py的:370 / :388 / :410 / :422 / :432(5 处)
-
两个 gate 包装器: 构造函数改为上文"关键接口"的签名;
QuotaGate4 处(ratelimit.py:30/38/46/54)与BreakerGate5 处(breaker.py:26/36/46/54/62)的raise补scope=self._scope。各方法开头的← 这条是错的,2026-08-06 独立验证时炸出(见 §T6)。正确做法: 该放行必须扩为except GovernanceBackendError: raise保持不变except (GovernanceBackendError, SourceNotConfiguredError): raise,否则新增的兄弟类型会落进下一行的except Exception被重新包成GovernanceBackendError,使 Q1 的拆分在唯一的生产路径上完全失效。
-
三处装配各传 scope(三处的
self._scope均已在装配前赋值,无需调整顺序):
| 文件 | 行 | 改为 |
|---|---|---|
middleware/retry.py |
186-187 | QuotaGate(limiter, scope=self._scope) / BreakerGate(gate, scope=self._scope) |
ocr.py |
122-123 | 同款 |
embedding.py |
123-124 | 同款 |
测试要求(先失败后通过,逐条对应):
| 用例 | 文件 | 改前为何失败 |
|---|---|---|
GovernanceBackendError 可被 except GatewayUnavailableError 接住,且 reason == "governance_backend_down"、retry_after_s == 5.0 |
tests/unit/test_errors.py |
改前非其子类,pytest.raises(GatewayUnavailableError) 不匹配 |
str(exc) 仍为构造时的诊断串(防 §3.5 回归) |
tests/unit/test_errors.py |
改前无该风险但改后若漏写 self.args 即失败,是回归护栏 |
三条泄漏路径(try_acquire / try_enter / progress_age_s)抛出的异常带正确 scope、且可被 except GatewayUnavailableError 接住 |
tests/unit/test_backpressure.py — 三条都要新增桩。现状: progress_age_s 只有 TestQuotaGateProgressAge(:243-257)覆盖包装行为、不验 scope;try_acquire(QuotaGate)与 try_enter(BreakerGate)完全无桩 |
改前异常无 scope 属性 → AttributeError;两条新路径改前无覆盖 |
未知源抛 SourceNotConfiguredError,且断言它不是 GatewayUnavailableError |
改 tests/unit/test_redis_key_layout.py:70-74(test_unknown_source_rejected,现断言 GovernanceBackendError);内存版当前无对应用例,需新增一条同款(backends/memory/limiter.py:92 的 _cfg("nope")) |
改前 redis 版类型断言失败;内存版改前无覆盖(该分支从未被测过) |
Redis 真实掉线时准入侧抛 scope 级异常且 reason == "governance_backend_down" |
tests/integration/test_redis_cross_connection.py:228-245(真实 Redis,不 mock) |
改前无 reason 属性 |
必须同批更新的既有测试构造点(新签名为 keyword-only 必填,漏改即 TypeError: missing required keyword-only argument,门禁直接红):
| 位置 | 现状 | 改为 |
|---|---|---|
tests/unit/test_backpressure.py:176 / :181 / :186 |
raise GovernanceBackendError("redis 抖动") |
补 scope=(任意测试 scope,如 "llm") |
tests/unit/test_errors.py:89 |
exc = GovernanceBackendError("redis down") |
同上;该用例现断言它不属于可重试分类,须一并改为断言它是 GatewayUnavailableError |
tests/unit/test_backpressure.py:255 / :257 |
QuotaGate(_L()) / QuotaGate(_Broken()) |
QuotaGate(_L(), scope="llm") 等 |
保真校验检查点: 提交前对照上文"保真校验"五条逐条自查,确认无一被顺带改动。特别核对 RedisPermit.release/settle(redis/limiter.py:133,151)的 except GovernanceBackendError 仍能接住释放侧失败——该处是设计 §4 否决"让原始异常穿透"路线的直接原因。
验证:
conda run -n PolyGateway pytest tests/unit tests/contracts -v→ 全 PASSconda run -n PolyGateway pytest tests/integration -v→ 全 PASS(需真实 Redis)conda run -n PolyGateway pytest tests/ -q→0 failedconda run -n PolyGateway radon cc src -n C -s→ 无输出make lint→ import-linter 契约全绿(本次不新增跨层依赖,应无变化)
提交: fix: reparent governance backend failures under GatewayUnavailableError (issue #7)
- [x] T4: 公开错误面文档(issue #7 第二诉求)
文件: 改 README.md(§"错误模型(四分类)",约 114-125 行)、research-wiki/migrations/chsanalyzer.md
行为:
- README 增一张两列表,明确区分会到达调用方与库内吸收:
| 会到达调用方 | 库内吸收 |
|---|---|
GatewayUnavailableError 族(CircuitOpenError / AllSourcesExhausted / GovernanceBackendError) |
TransientError |
RequestRejectedError |
SourceDeadError |
ResultInvalidError |
|
SourceNotConfiguredError |
- 在该表下补一句说明:
TransientError/SourceDeadError的 docstring 描述的是库内治理行为,它们被middleware/retry.py:365接住并在预算耗尽时包成AllSourcesExhausted,不会到达调用方——issue #7 记载下游曾据此写错整段设计文档。 migrations/chsanalyzer.md的 G1 条目补注:后端故障现已并入GatewayUnavailableError,项目侧except GatewayUnavailableError一条即覆盖完整,无需为GovernanceBackendError单列分支。
验收: 调用方仅读 README 即可判断该 catch 什么,无需读 middleware/retry.py。
测试要求: 纯文档,无测试证据要求。
验证: grep -n "库内吸收" README.md → 命中。
提交: docs: publish which errors reach callers and which the library absorbs
- [x] T5: 版本 1.1.0 + CHANGELOG + Wiki 同步
文件: 改 pyproject.toml(version)、src/polygateway/__init__.py(__version__)、CHANGELOG.md;按 research-wiki/docs-convention.md §2 同步 Gitea Wiki
行为: 版本 1.0.6 → 1.1.0(有行为变更但无 API 破坏:加父类是扩大)。CHANGELOG 需写明:
- 行为变更: 后端故障从"落入调用方兜底分支"变为"被
except GatewayUnavailableError捕获";下游据此把它按"延期重投、不消耗失败预算"处置,这正是修复目标,但处置路线确实变了,升级前须确认下游的兜底分支没有依赖它。 - 新增:
SourceNotConfiguredError(公共导出)、GOVERNANCE_BACKEND_RETRY_AFTER_S、scope 级 reasongovernance_backend_down。 - 下游请读:
GovernanceBackendError现携带scope/reason/retry_after_s(默认 5.0)/per_source_reasons;str(exc)仍是原诊断串,结构化字段并存。配置写错(源名不匹配)现在抛SourceNotConfiguredError而非GovernanceBackendError,它不属于可重投家族——这是有意的,目的是让装配缺陷进死信而不是永远重投。
验收: 版本三处一致(pyproject.toml / __init__.py / CHANGELOG 标题);CLAUDE.md §6 要求"版本 bump 提交不得裸发",故本任务必须与 wiki 同步同批。
测试要求: 无行为变更,pytest tests/ -q 保持全绿即可。
验证: grep -n "1.1.0" pyproject.toml src/polygateway/__init__.py CHANGELOG.md → 三处命中。
提交: chore: release 1.1.0
- [x] T6: 修复独立验证炸出的阻塞缺陷(计划外,2026-08-06)
T1–T5 全绿、全部门禁通过之后,全新上下文的 verifier 用一个走 QuotaGate 的端到端用例炸出:装配缺陷在唯一的生产路径上根本没有拆出去。
缺陷: QuotaGate/BreakerGate 的 except GovernanceBackendError: raise 只放行了旧类型,新增的 SourceNotConfiguredError 落进下一行 except Exception 被重新包成 GovernanceBackendError(reason=governance_backend_down、retry_after_s=5.0)。实证:
RAISED: GovernanceBackendError | isGatewayUnavailable=True | isSourceNotConfigured=False
| 限流后端故障(source_stats): 未知源 's1'(scope=llm)
即配置写错的任务照样落进"可延期重投"家族,永远重投、永不进死信、无人告警——正是 Q1 要防的镜像 bug,G2 等于没做。
为什么原有测试测不出来: T3 写的两条用例(test_backpressure.py、test_redis_key_layout.py)都直接打私有 _cfg(),绕过了包装器;而治理循环只经包装器访问后端。盲区在于测试打的层次比生产路径低一层。
修复(三处):
| 文件 | 改动 |
|---|---|
middleware/ratelimit.py |
4 个方法的放行扩为 except (GovernanceBackendError, SourceNotConfiguredError): raise |
middleware/breaker.py |
同上,5 个方法 |
middleware/telemetry.py:254 |
终态捕获元组加 SourceNotConfiguredError。连带坑: 放行生效后该异常不再是 GovernanceBackendError,而它在任何 attempt 之前抛出,若不显式捕获则 emit_terminal_failure 不触发、该路径遥测归零,违反"遥测必录"铁律 |
回归测试: test_backpressure.py::TestUnknownSourceIsAssemblyDefect::test_survives_the_quota_gate_wrapper(参数化覆盖 try_acquire / stats),走包装器而非私有方法。修前 2 failed,修后 PASS。
同批文档订正: 泄漏路径由"三条"改为五条(遗漏了 QuotaGate.stats 与 BreakerGate.retry_after_s,判据是该调用点是否被 _record_quietly 包裹);CHANGELOG 的 per_source_reasons 表述改为"属性存在但恒为 {}"。
完成后
按 CLAUDE.md §3 Phase 2,合并前须派全新上下文的 verifier subagent 做独立验证(verification-before-completion),并按新规则前台运行。随后走 finishing-a-development-branch 决定合并方式,并在 Gitea 关闭 issue #7。