From 1fa91cf73d4f67878609f2ee5dc2bc8f0b64431a Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 6 Aug 2026 04:11:17 -0400 Subject: [PATCH] docs: add the implementation plan for issue #7 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. --- ...6-08-06-governance-backend-error-design.md | 8 +- .../designs/governance-backend-error.md | 2 +- research-wiki/graph/edges.json | 12 + research-wiki/index.md | 6 +- research-wiki/log.md | 4 + ...026-08-06-governance-backend-error-plan.md | 263 ++++++++++++++++++ .../plans/governance-backend-error.md | 37 +++ 7 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 research-wiki/plans/2026-08-06-governance-backend-error-plan.md create mode 100644 research-wiki/plans/governance-backend-error.md diff --git a/research-wiki/designs/2026-08-06-governance-backend-error-design.md b/research-wiki/designs/2026-08-06-governance-backend-error-design.md index 7723631..cf01841 100644 --- a/research-wiki/designs/2026-08-06-governance-backend-error-design.md +++ b/research-wiki/designs/2026-08-06-governance-backend-error-design.md @@ -31,7 +31,7 @@ | 事实 | 证据 | 含义 | |---|---|---| -| 库内仅一处 `except GatewayUnavailableError` | `middleware/telemetry.py:210`,写法为 `except (GatewayUnavailableError, GovernanceBackendError)` | 变成父子关系后该处由"并列捕获"退化为"父类捕获",**行为逐字不变**,库内零回归 | +| 库内仅一处 `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` | 注入无需新增上游参数传递链 | @@ -108,7 +108,7 @@ Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重 | 限流/熔断后端不可用 → 报错而非放行(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` | **保留**,父子关系后由父类分支承接,行为不变 | +| 遥测对后端故障发 `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 显式保全) | @@ -131,9 +131,9 @@ Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重 | 测试 | 位置 | 先失败后通过的证据 | |---|---|---| | `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` | +| 三条泄漏路径(`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:71-73`、内存版对应用例 | 改前抛 `GovernanceBackendError`,断言"不是 scope 级"必失败 | +| 未知源抛 `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. 影响面清单 diff --git a/research-wiki/designs/governance-backend-error.md b/research-wiki/designs/governance-backend-error.md index dd69201..a126bde 100644 --- a/research-wiki/designs/governance-backend-error.md +++ b/research-wiki/designs/governance-backend-error.md @@ -15,7 +15,7 @@ date: 2026-08-06 | 决策 | 选定 | 关键理由 | |---|---|---| -| A 类型树 | `GovernanceBackendError` 改继承 `GatewayUnavailableError`,`SCOPE_REASONS` 增 `governance_backend_down`,`reason` 恒为该值 | 加父类是**扩大**不是破坏(既有 `except GovernanceBackendError` 照旧命中);库内仅 `telemetry.py:210` 一处捕父类且已并列写两者,**零回归** | +| A 类型树 | `GovernanceBackendError` 改继承 `GatewayUnavailableError`,`SCOPE_REASONS` 增 `governance_backend_down`,`reason` 恒为该值 | 加父类是**扩大**不是破坏(既有 `except GovernanceBackendError` 照旧命中);库内仅 `telemetry.py:250` 一处捕父类且已并列写两者,**零回归** | | B `retry_after_s` | 模块常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,非环境配置项 | 后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻);取 0 会让积压任务零延迟批量重投,把一次故障放大成风暴 | | C scope 来源 | 后端层用 `self._scope`;`QuotaGate`/`BreakerGate` 构造函数注入,三处装配(`retry.py`/`ocr.py`/`embedding.py`)各传一行 | 两个包装器是后端异常的唯一入口,注入点收敛;三处装配本就持有 `self._scope` | | D 未知源拆分 | `_cfg()` 的 2 处改抛新增的 `SourceNotConfiguredError`,**有意不放在** `GatewayUnavailableError` 之下 | 那是装配缺陷不是后端故障;随整类归入"可重投"会让配置写错的任务永远重投、永不进死信——本 issue 要修的 bug 的镜像 | diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index b74a331..afd8060 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -135,6 +135,11 @@ "id": "design:governance-backend-error", "label": "治理后端故障归位为 scope 级不可用(Issue #7)", "type": "design" + }, + { + "id": "plan:governance-backend-error", + "label": "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)", + "type": "plan" } ], "links": [ @@ -242,6 +247,13 @@ "relation": "implements", "evidence": "T1-T10 逐条实现设计的 D1-D6 六个决策与 §11 九条验收标准", "added": "2026-08-02T09:49:48.126539+00:00" + }, + { + "source": "plan:governance-backend-error", + "target": "design:governance-backend-error", + "relation": "implements", + "evidence": "T1-T5 逐任务实现设计 §3 的五项决策与 §8 影响面清单", + "added": "2026-08-06T08:08:51.865565+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 1aa8252..2e475b3 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-06 06:38 UTC +> 自动生成,更新时间:2026-08-06 08:11 UTC ## design (23) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -41,7 +41,7 @@ - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` -## plan (17) +## plan (19) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -50,6 +50,7 @@ - [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan` - [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields` - [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params` +- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` @@ -57,6 +58,7 @@ - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` +- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` - [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` diff --git a/research-wiki/log.md b/research-wiki/log.md index 4752357..4684e4f 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -86,3 +86,7 @@ - [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测) - [2026-08-06 06:37 UTC] 新增 design: 治理后端故障归位为 scope 级不可用(Issue #7) (design:governance-backend-error) - [2026-08-06 06:38 UTC] 重建索引: 55 篇页面 +- [2026-08-06 08:08 UTC] 新增 plan: 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7) (plan:governance-backend-error) +- [2026-08-06 08:08 UTC] 新增边: plan:governance-backend-error --implements--> design:governance-backend-error +- [2026-08-06 08:08 UTC] 重建索引: 57 篇页面 +- [2026-08-06 08:11 UTC] 重建索引: 57 篇页面 diff --git a/research-wiki/plans/2026-08-06-governance-backend-error-plan.md b/research-wiki/plans/2026-08-06-governance-backend-error-plan.md new file mode 100644 index 0000000..6758adb --- /dev/null +++ b/research-wiki/plans/2026-08-06-governance-backend-error-plan.md @@ -0,0 +1,263 @@ +# 实现计划: 治理后端故障归位为 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`(新 reason `governance_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` 新增与变更部分: + +```python +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 必填): + +```python +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` + +**行为**: +1. 加模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`(docstring 逐字见上文"关键接口"); +2. `SCOPE_REASONS` 增 `"governance_backend_down"`; +3. 新增 `SourceNotConfiguredError(PolyGatewayError)`(定义逐字见上文); +4. `__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`,门禁跑不过。 + +**行为**: + +1. `errors.py`: `GovernanceBackendError` 改继承 `GatewayUnavailableError` 并覆写 `__init__`(逐字见上文"关键接口")。 + +2. **两处未知源改抛新类**(设计 §3.4,Q1 已拍板): + +| 位置 | 改为 | +|---|---| +| `backends/redis/limiter.py:198` | `raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")` | +| `backends/memory/limiter.py:92` | 同上 | + +3. **后端层 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 处) + +4. **两个 gate 包装器**: 构造函数改为上文"关键接口"的签名;`QuotaGate` 4 处(`ratelimit.py:30/38/46/54`)与 `BreakerGate` 5 处(`breaker.py:26/36/46/54/62`)的 `raise` 补 `scope=self._scope`。 + - 各方法开头的 `except GovernanceBackendError: raise` **保持不变**(后端层已填好 scope,重建实例只会重复构造,设计 §3.3)。 + +5. **三处装配各传 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` → 全 PASS +- `conda run -n PolyGateway pytest tests/integration -v` → 全 PASS(需真实 Redis) +- `conda run -n PolyGateway pytest tests/ -q` → `0 failed` +- `conda 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` + +**行为**: +1. README 增一张两列表,明确区分**会到达调用方**与**库内吸收**: + +| 会到达调用方 | 库内吸收 | +|---|---| +| `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError`) | `TransientError` | +| `RequestRejectedError` | `SourceDeadError` | +| `ResultInvalidError` | | +| `SourceNotConfiguredError` | | + +2. 在该表下补一句说明: `TransientError` / `SourceDeadError` 的 docstring 描述的是**库内治理行为**,它们被 `middleware/retry.py:365` 接住并在预算耗尽时包成 `AllSourcesExhausted`,**不会**到达调用方——issue #7 记载下游曾据此写错整段设计文档。 +3. `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 级 reason `governance_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。 diff --git a/research-wiki/plans/governance-backend-error.md b/research-wiki/plans/governance-backend-error.md new file mode 100644 index 0000000..f72eedd --- /dev/null +++ b/research-wiki/plans/governance-backend-error.md @@ -0,0 +1,37 @@ +--- +type: plan +node_id: plan:governance-backend-error +title: "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)" +date: 2026-08-06 +--- + +# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7) + +全文见 `2026-08-06-governance-backend-error-plan.md`。实现设计 [[governance-backend-error]](已批准 2026-08-06)。 + +## 五个任务 + +| # | 任务 | 关键约束 | +|---|---|---| +| T1 | `ARCHITECTURE.md` §6.1 回补两行 + reason 值域扩为 6 值 | **必须先行**——单一事实源纪律,先改代码后补文档等于让实现与事实源脱节(设计 §8.1) | +| T2 | `errors.py` 纯增量: 新常量、新 reason、`SourceNotConfiguredError` + 顶层导出 | 刻意不动 `GovernanceBackendError`,故全套件保持通过,可独立提交 | +| T3 | `GovernanceBackendError` 归位 + 22 处构造点 + gate scope 注入 + 全部受影响测试 | **必须原子**: `scope` 是必填 keyword,分批提交的中间状态会 `TypeError` | +| T4 | README 公开错误面两列表 + 迁移文档补注 | issue #7 的第二诉求,作者认为比第一条更值得改 | +| T5 | 版本 1.1.0 + CHANGELOG + Wiki 同步 | 加父类是扩大不是破坏,故 minor 而非 major | + +## 保真校验(适用) + +触及 ARCHITECTURE §1.4 的移植蓝本(CHS `app/domain/errors.py` 与 `app/coordination/`)。本次**有意变更**的语义仅一条(`GovernanceBackendError` 的类型归属);`retry_after_s` 非可选语义、两个 reason 值域、fail-closed 方向、记账/闸门分工、`RedisPermit` 释放侧降级五条**不得被顺带改动**,每任务完成前逐条自查。 + +## 独立审查修正(2026-08-06, Codex) + +4 条意见全部核实属实并已折回: + +1. **T3 测试证据定位错误**(重要)——原写"复用 `test_backpressure.py:176-186` 的注入桩"覆盖三条泄漏路径,实测那三个桩是 `record_success`/`record_failure`/`mark_progress` 的**记账侧降级**,与闸门路径无关;`try_acquire`/`try_enter` 全无覆盖。已改为"三条桩都要新增"并写明现状。 +2. **T3 漏了既有测试构造点**(重要)——新签名 keyword-only 必填,`test_backpressure.py:176/181/186`、`test_errors.py:89` 的裸 `GovernanceBackendError("...")` 与 `:255/:257` 的 `QuotaGate(_L())` 漏改即 `TypeError`。已补一张同批更新清单。 +3. **`__all__` 插入位置写反**(次要)——按字母序应在 `SourceDeadError` **之后**而非之前。已改。 +4. **设计中 telemetry 行号过时**(次要)——`:210` → `:250`,系本分支加 `_AttemptUsage` 造成的漂移。设计与摘要页已同步更新。 + +Codex 同时独立核实了计划的可执行性锚点: 22 处构造点、三处 gate 装配、后端层 `self._scope` 位置、README/ARCH 章节行号,均与 `src/` 现状相符。 + +相关: [[governance-backend-error]](design)、[[m2-distributed]]