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.
16 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
任务清单
- [ ] 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
- [ ] 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
- [ ] 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。- 各方法开头的
except GovernanceBackendError: raise保持不变(后端层已填好 scope,重建实例只会重复构造,设计 §3.3)。
- 各方法开头的
-
三处装配各传 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)
- [ ] 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
- [ ] 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
完成后
按 CLAUDE.md §3 Phase 2,合并前须派全新上下文的 verifier subagent 做独立验证(verification-before-completion),并按新规则前台运行。随后走 finishing-a-development-branch 决定合并方式,并在 Gitea 关闭 issue #7。