The breaker conflates "this source is unhealthy" with "kill this call
now". Limiter rejections already choose between wait and fail_fast;
breaker rejections had no such choice, so a single-source scope loses
its whole retry budget the moment the gate opens.
Design adds {SCOPE}__CIRCUIT_OPEN (default fail_fast, so existing
deployments keep their control flow) and pins retry_after_s to "time
until a *certain* retry moment" across all six gate exits. The latter
also fixes a separate bug the issue missed: a half-open rejection fed
the probe lease (up to 2x timeout) into the source cooldown memo, whose
set_until only moves forward -- so a recovered source stayed blacklisted
in-process long after the gate closed. That one bites multi-source
deployments too, it is just hidden when other sources absorb the load.
Human-approved 2026-08-19; both documents revised after Codex review.
19 KiB
熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦
- issue: #14(dissect,单源第三方中转部署)
- 核查基准: HEAD 1.2.3;issue 按 1.2.1 提交,逐条复核后全部仍然成立(
backends/memory/breaker.pymd5630ed36ddeb87e08a9bac58260056046,1.0.6→1.2.3 逐字节未变) - 状态: 人类已确认(2026-08-19);经 Codex 审查修正(2026-08-19,修正点见 §3.1/§3.4/§3.5/§6 标注),待实施
1. 问题的真实形状
issue 把问题命名为"单源 scope 下熔断等于整体停服"。这个命名会把方案引向错误的方向——单源不是病因,是让病灶 100% 复现的放大器。三条独立缺陷叠加成了现场那 30 次瞬死,必须分开命名才修得干净。
1.1 缺陷一: 准入策略矩阵缺了一格
_pick_runnable 有四种"拒绝",库对它们的处置并不对称:
| 拒绝原因 | 计入 gate_rejections |
全被拒时的处置 | 可配? |
|---|---|---|---|
rate_limited(permit 拿不到) |
否 | 走 quota_full 分支 |
是(wait/fail_fast) |
adaptive_paced(AIMD 超限) |
否 | 走 quota_full 分支 |
是(同上) |
circuit_open(熔断门拒) |
是 | 当场抛 CircuitOpenError |
否 |
cooldown(源冷却备忘) |
是 | 同上 | 否 |
限流闸满时库不判死、允许排队(quota_full=wait,缺省);熔断门拒时库只有 fail-fast 一档且不可配。两者在准入语义上完全同构(都不发请求、都带 retry_after 提示),处置却分叉。
这一格的缺失与源数量无关:多源全部同时开路(共同上游的中转挂了、一次全网抖动)时行为一模一样。单源只是把"全部开路"的概率从"罕见"变成"必然"。因此任何形态的单源特判(if len(sources) == 1)都是错的——它会让行为随池大小突变、无法组合测试,是比现状更重的债。
1.2 缺陷二: retry_after_s 在 HALF_OPEN 下返回了一个物理上无意义的数
try_enter 在 HALF_OPEN 拒绝时返回 probe_expires - now,即探针租约的剩余时长。而 probe_ttl_s 派生自 max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)(config.py:400-407),现场 TIMEOUT_S=300 ⇒ 600 秒,而冷却期只有 60 秒。
探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(backends/redis/breaker.py 的 TRY_ENTER/RETRY_AFTER 两个 Lua 均返回 probe_until - now)。
1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约
缺陷二的值被喂进了源冷却备忘:
retry.py:354 self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
sources.py:139 self._until[name] = max(已有, until) # 取更晚者,不可回退
于是:源 A 冷却到期 → 调用 1 拿到探针 → 并发的调用 2 被拒、拿到 600 → 给 A 记 600 秒本地冷却 → 调用 1 的探针成功、门恢复 CLOSED → 本进程此后 600 秒仍然跳过 A,且 reasons[A]="cooldown" 计入 gate_rejections,单源下每次调用照旧抛 CircuitOpenError。
实测复现(InMemoryGate + 注入时钟,cooldown_s=60、probe_ttl_s=600):
B 决定: allowed=False state=half_open retry_after_s=600.0 <- 冷却只有 60s
B 给 s1 记的本地冷却剩余: 600.0 秒
探针成功后门 state: closed
门已 CLOSED,memo.active('s1') = True
再过 120 秒(远超 60s 冷却)memo.active = True 剩余 480.0 秒
这条与源数量、与是否单源都无关:多源部署里,一个源每开路一次就会被本进程从池中除名 probe_ttl_s(可达 2 × timeout),池子越大越难被观测到,因为别的源接住了流量。现场那"30 次瞬死横跨 20 秒"里有多少来自这一条无法反推,但机制确凿。
2. 备选方案与否决理由
issue 给了 A/B/C/D 四条。逐条判:
| 方案 | 判定 | 理由 |
|---|---|---|
A PGW_BREAKER_BACKEND=noop |
否决 | 关掉的是"保护"(401/403/配额耗尽的一击即熔一并失效,坏密钥持续撞墙),而诉求是"别当场判死"。且开了"治理组件可整个关掉"的先例,限流迟早跟进。三条缺陷一条都不解决 |
B {SCOPE}__CIRCUIT_OPEN=wait|fail_fast |
采纳为主干 | 与 quota_full 严格同构,补的正是 §1.1 那一格。但 issue 版的 B 未答"wait 档等多久",而这个答案依赖 C |
C 修 HALF_OPEN 的 retry_after_s |
采纳,且不是"治标" | issue 把它列为"可并行的小修"。实际上它是 B 的前提:wait 档要按 retry_after 睡,睡一个 600 秒的假数就是新事故。它还是 §1.3 的病根 |
| D 只写文档 | 否决 | 把配置项的副作用固化成公开契约,将来动阈值逻辑即破坏;且解决不了 force_open |
方案 = B + C,合并为一件事:B 依赖 C 的正确性,C 修完 §1.3 自动消失。
3. 设计
3.1 retry_after_s 的契约定死为"确定的最早可尝试时刻"
| 门状态 | 返回值 | 依据 |
|---|---|---|
| CLOSED | 0.0 |
现状,不变 |
| OPEN | open_until - now |
现状,不变。冷却截止是确定时刻 |
| HALF_OPEN(被拒) | 0.0 |
探针随时可能出结果,不存在确定的等待时刻 |
0.0 不是新约定:errors.py 早已定义 retry_after_s 的 0 = 可立即重试,契约测试 test_retry_after_semantics 也以"健康 → 0、冷却到期 → 0"钉着这个语义。HALF_OPEN 归入"无确定等待"是同一语义的自然延伸,而非发明。
信息不丢失:GateDecision.state 已经携带 HALF_OPEN,调用方要区分"门闭着"与"探针在途"照样能区分。
惊群由既有机制承担,不由这个数承担:门自身的单探针租约保证第二个 caller 拿不到名额;wait 档的复查间隔由 middleware 的 poll_interval_s 抖动睡眠承担(§3.3)。
§1.3 随之闭合:set_until(now + 0.0) 写入一个已过期的截止时刻,active() 恒 False——HALF_OPEN 拒绝自此不再污染备忘,无需在 retry.py 加任何状态分支。备忘回归它唯一正当的用途:记 OPEN 的确定冷却期。
准入被允许时恒 0.0:allowed=True 意味着现在就能试,这个字段没有别的合理取值。
改动面是五个出口,不是两个(Codex 审查修正)。原稿只点了 try_enter 与 retry_after_s(),漏了 GateUpdate 那一侧;逐一核实后发现两个后端在这两处本就已经分叉——本 issue 的病根正是"retry_after_s 语义从未被定死,于是各后端各自发挥",不一并收口就是定了新契约却留两个后端不遵守:
| 出口 | memory 现状 | redis 现状 | 统一为 |
|---|---|---|---|
try_enter 拒绝(OPEN) |
open_until - now |
同 | 不变 |
try_enter 拒绝(HALF_OPEN) |
probe_expires - now |
probe_until - now |
0.0 |
try_enter 授予探针 |
0.0(memory:114) |
probe_ttl_ms(redis:53) |
0.0(redis 侧改) |
GateUpdate(fencing 未命中,HALF_OPEN) |
0.0(memory:175-177 非 OPEN 一律 0) |
probe_until - now(redis:127/158/258) |
0.0(redis 侧三处改) |
retry_after_s() 跨源取 min |
HALF_OPEN 记 probe_expires - now |
同 | HALF_OPEN 记 0.0 |
后两行是既有缺陷,与本 issue 同源、由契约测试盲区掩护至今(现有用例只钉"第二个进入者被拒",没钉它拿到什么数)。同源缺陷一并修,不作为独立议题。
memory 侧抽 _remaining(g) 私有纯方法供三处共用;redis 侧四个 Lua(TRY_ENTER/RECORD_SUCCESS/RECORD_FAILURE/RELEASE_PROBE)与 RETRY_AFTER 各改一处(Lua 无法共享函数,这是既有约束,_WINDOW_HELPERS 已是同款处理),由同一批双后端参数化契约用例锁死。
3.2 新配置键 {SCOPE}__CIRCUIT_OPEN
与 quota_full 逐项对齐,不发明新形状:
| 维度 | quota_full(既有) |
circuit_open(新增) |
|---|---|---|
| 合法域 | _QUOTA_FULL = {"wait","fail_fast"} |
_CIRCUIT_OPEN = {"wait","fail_fast"} |
| 缺省 | wait |
fail_fast(见 §3.5) |
| env 键 | {SCOPE}__QUOTA_FULL |
{SCOPE}__CIRCUIT_OPEN |
| 装配 | settings → GatewayClient → 三条循环 |
同 |
| 校验 | _validate_backends 表驱动 + 构造期 |
同(各加一行) |
改动面: config.py(常量 / 字段 / 校验元组 / from_env 各一行)、client.py(签名 + 透传各一处)、SourceAdmission(§3.4)一处。
3.3 _on_no_runnable 的控制流
现状两个分支是串行的。今天走不到那个坑(没有 wait 档,第一分支必抛),但只要把第一分支改成"wait 时不抛"就会立刻踩中:控制流会往下掉进 quota_full 分支,quota_full=fail_fast 的调用方会看到熔断等待被误报成 reason="quota_exhausted"。必须改成按拒绝原因分派:
if gate_rejections == len(sources): # 全部因熔断类原因被拒
if circuit_open == "fail_fast": raise CircuitOpenError(retry_after=gate.retry_after_s(names))
hint = await gate.retry_after_s(names) # OPEN 有确定值;全 HALF_OPEN 得 0
else: # 至少一源是被配额/AIMD 挡的
if quota_full == "fail_fast": raise AllSourcesExhausted("quota_exhausted")
hint = 0.0
if await self._stalled(clock): raise AllSourcesExhausted("stalled", ...)
await self._sleep(self._nap(hint, clock))
睡眠时长 _nap(hint, clock),三条约束同时满足:
| 约束 | 实现 | 理由 |
|---|---|---|
| 不空转 | hint > 0 时睡到冷却结束再加抖动,而非 50ms 轮询 |
60 秒冷却下,poll_interval=0.05 会产生 1200 次无谓复查;memory 后端只是字典查询,redis 后端是 1200 次往返 × 每个在途调用 |
| 不白醒 | 抖动上加(hint + poll_interval × (0.5+0.5×rng)),不缩放 |
对一个确定的截止时刻提前醒必然被再拒一次 |
| 等待有可解释上界 | 夹到剩余 stall 预算:min(睡眠, stall_window - clock.stalled_s()),下界 poll_interval |
最迟在 stall 窗口耗尽那一刻醒来判死,单次调用最坏墙钟 = stall_window_s(缺省 300s),不随 max_cooldown_s 漂移 |
hint = 0 时该式退化为现有的 poll_interval × (0.5+0.5×rng),配额等待路径逐字不变。
计时归属无需改动:这段睡眠发生在 clock.attempting() 之外,自动计入 stall 账,与 ARCH §7.3 "熔断冷却属非生产性等待"的既定口径一致。
3.4 前置收敛: 准入逻辑三处复制归一
_pick_runnable / _on_no_runnable 目前在 middleware/retry.py、embedding.py、ocr.py 各有一份,后两份是第一份的逐字子集(少 AIMD pacer 与调用内降权)。若只改 chat 一处,embedding/ocr 就成了行为分叉的角落——那才是本次真正会留下的技术债(CLAUDE.md 铁律痛斥的"三项目 4 处复制"的库内同款)。
抽 middleware/admission.py::SourceAdmission,持有 sources/selector/QuotaGate/BreakerGate/memo/backpressure/两个策略键/时钟三件套,暴露 pick() 与 on_no_runnable()。三条循环的差异用注入表达,不留分支:
| 差异 | 处理 | 行为等价性 |
|---|---|---|
| 调用内降权(仅 chat) | attempt_fails 作 pick() 入参 |
embedding/ocr 传空 dict 时 _demote_call_failures 恒等返回原序(demoted 为空即 return ordered) |
| AIMD pacer(仅 chat) | pacer: AdaptivePacer | None = None |
None 时跳过 admit/enter,无副作用 |
_settle_and_release 三份复制 |
提为 middleware/ 模块级 async 函数 |
chat/embedding 签名为 (permit, actual),OCR 为 (permit) 且体内恒 settle(0)(ocr.py:438,Codex 审查补)。OCR 侧改为传 0,逐字等价;唯一可见变化是 warning 文案由"OCR permit 结算/释放失败"归一 |
已逐字 diff 核实(embedding 与 ocr 两份完全相同;chat 多出的只有上表三类)。另有两处不在抽取边界内、须原样保留:chat 主循环顶部额外的一次 _stalled 预判(retry.py:286),以及 OCR 的健康喂数——它们属于各自的主循环与 _attempt,本次一行不动。
这不是任务外重构:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——StallClock 与 backoff_delay 已按同一原则收敛为共享单元(ARCH §7.3)。边界严格限定在准入与无源可跑的处置,_attempt 一行不动(三者差异大: 流式 / 批 / 图)。
执行分两个提交:①纯重构,验收标准是全套件逐字绿、无行为变更;②在单一位置加语义。①先行以保回滚点。
3.5 缺省值取 fail_fast
quota_full 缺省 wait,但 circuit_open 不跟随,理由是变更方向的危险性不对称:
| 取值 | 对存量下游的影响 |
|---|---|
fail_fast(采纳) |
控制流逐字不变(全源被熔断拒仍当场抛 CircuitOpenError) |
wait |
把所有人的最坏墙钟从毫秒抬到 stall_window_s,且是"快速失败 → 长时间挂起"这个最危险的方向 |
issue 的诉求本身也不是改默认值,而是表达能力——其 §2.3 的原话是"库对这两种情形用的是同一套默认值、且不允许调用方表达自己属于哪一种"。多源下 fail-fast 确实是对的(换源比等待快),单源下调用方显式配 wait 即可。README 与 wiki 需明写"单源 scope 建议配 wait"。
3.6 errors.py 的职责边界补写
issue 要求修订 GatewayUnavailableError 那句"业务侧 catch 本类做延期重投"——它读起来像在鼓励每个下游各写一份重试逻辑。改为明确边界:调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是任务级重试,语义与调用级重试不同。
这不是新决策,是把 ARCH §7.2 已经写明的"单层重试原则"补进 docstring。零代码风险。
"缺省档零感知"须诚实收窄(Codex 审查修正): 缺省档保证的是控制流不变,不是零可见变更。retry_after_s 的语义修正在缺省档下同样生效——全源 HALF_OPEN 时 CircuitOpenError.retry_after_s 由"探针租约剩余"变为 0.0,而它是公开字段(errors.py:118)。这正是本次记 1.3.0 而非补丁号、且 CHANGELOG 需"请先读这一条"待遇的原因。另需注意 GatewaySettings 全部字段均无默认值(既有风格),新增 circuit_open 沿用之,直接构造该类的调用方须补一个参数。
4. 行为矩阵
| 场景 | fail_fast(缺省,= 现状) |
wait |
|---|---|---|
| 单源 OPEN,冷却 60s | 立即 CircuitOpenError(retry_after=剩余冷却) |
睡到冷却结束(夹在 stall 预算内)→ 探针 → 成功即返回 |
单源 force_open(401/403) |
立即失败 | 等 60 → 探针又 401 → 等 120 …… 直至 stall 判死(≤300s)。代价须进文档 |
| 多源部分开路 | 不变(有源可跑就不进这个分支) | 不变 |
| 多源全部开路 | 立即失败 | 等最早恢复的那个源(retry_after_s 取 min) |
| 全部 HALF_OPEN(探针在途) | CircuitOpenError(retry_after=0),语义准确(随时可能好) |
poll_interval 抖动复查,秒级拿到探针结果 |
| 配额满 / AIMD 超限 | 归 quota_full 管,逐字不变 |
逐字不变 |
5. 测试策略
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。分三层:
契约层(tests/contracts/test_breaker_contract.py,双后端参数化自动覆盖 memory + redis):
按 §3.1 那张表逐个出口钉——HALF_OPEN 被拒、授予探针、GateUpdate fencing 未命中、retry_after_s() 探针在途,四处均须 == 0.0;OPEN 语义不变(现有 test_retry_after_semantics 保持绿)。现有用例只钉了"第二个进入者被拒",没钉它拿到什么数,正是这个盲区放过了两处双后端分叉。Redis 侧依赖时间快进的变体在契约层会 skip,须同步补 tests/integration/test_redis_governance_time.py 的真实等待变体(既有约定,不缩放时长)。
单元层(tests/unit/test_backpressure.py 邻域,注入时钟/睡眠/rng):
§1.3 的回归钉子——探针成功后备忘不再屏蔽该源(直接由 §3.1 的复现脚本转化);circuit_open=wait 下全源开路不抛 CircuitOpenError 而按 retry_after 睡;wait + quota_full=fail_fast 组合下熔断等待不被误报成 quota_exhausted(§3.3 那个坑的钉子);wait 档最坏墙钟 ≤ stall_window_s 且判死 reason 为 stalled、per_source_reasons 含 circuit_open;fail_fast 缺省下全部现有用例逐字绿。
收敛层: §3.4 的重构提交以"三条循环现有测试全绿、零新增用例"为验收——有新增用例即说明行为被动了。
6. 非功能与已知取舍
| 维度 | 结论 |
|---|---|
| 取消穿透 | _nap 的长睡眠是 await self._sleep(...),CancelledError 逐字穿透;无新增 finally 资源 |
| 后端往返 | wait 档每个冷却周期约 1 次 gate 查询(vs. poll_interval 轮询的 1200 次),Redis 压力低于按现状实现的朴素 wait |
| 遥测 | 不加列。wait 等待期不发请求,无 attempt 行可记;调用级总等待下游可自测。进入/退出等待各打一条 logger.info(scope、per-source reasons、预计等待),使"等了多久"可从日志还原 |
| 等待上界的精确值 | _stalled 判据是 > 而非 >=(retry.py:368,Codex 审查补)。睡眠恰好夹到剩余预算时,醒来 stalled_s() 等于窗口而不大于,不判死。故 _nap 夹到 剩余预算 + poll_interval_s,一次到位;最坏墙钟精确表述为 stall_window_s + 一个 poll 间隔,不是"恰好 stall_window_s" |
| 备忘的跨进程滞后 | 本进程记了 OPEN 冷却后,即便别的进程的探针已把共享门关回 CLOSED,本进程仍会跳到本地备忘自然过期(_pick_runnable 先查备忘再问门)。这是备忘"以本地记录换 Redis 往返"的固有代价,误差有界(≤ 一个 cooldown),既有性质、本次不改;备忘是进程内存,无持久化,故不存在滚动升级残留 |
| 无限等待 | _stalled 是双条件合取,同 scope 其他调用仍在出餐时本调用不判死(ARCH §7.3 已承认的残余性质)。单源全开路时无人出餐,条件 B 必然成立,会判死;多源部分开路则走不到这个分支。文档沿用既有措辞:需要硬上限的调用方自行 asyncio.wait_for |
| 未解决 | force_open 在 wait 档下把坏密钥的失败从毫秒拖到 stall 窗口。有意不特判——库无法区分"密钥坏了"与"中转抖了",选 wait 即声明"宁可等也不当场死" |
7. 文档与发布
ARCH §7.4 增补本次决策与三条缺陷的成因;§9 配置面登记新键;README 能力表与配置表;Gitea wiki 按 docs-convention.md §2 同步;CHANGELOG 记为 1.3.0(新增配置键 + retry_after_s 语义变更,后者对下游可见,需"请先读这一条"待遇)。
GateDecision 的字段与 ProviderGate 端口签名均不变,故不触碰迁移兼容约束(ARCH §5.1)。
8. 已定决策(人类,2026-08-19)
| # | 决策 | 随之固定的实施边界 |
|---|---|---|
| 1 | 缺省取 fail_fast(§3.5) |
存量下游零感知;issue 提交方需自行加 {SCOPE}__CIRCUIT_OPEN=wait。README/wiki 必须明写"单源 scope 建议配 wait",否则这个开关等于不存在 |
| 2 | §3.4 的三处收敛本次一并做 | 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点 |