单源 scope 下熔断开路等于整体停服,而关掉它只能靠把两个阈值参数当开关用 #14
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
2af445c(1.2.1)源码,非线上装的 1.0.6requirements.txt钉的是polygateway>=1.0.6,<1.1)runs/_gateway_telemetry.db逐次遥测 + 业务侧记账库0. 先说清楚这个 issue 不是什么
我们先按 1.2.1 源码把两次故障各自复核了一遍,其中一次已经被 issue #8 修掉了,
这一条不再诉求任何东西,写在这里是为了让"还剩什么"这句话可信:
stalled,max_attempts=8一次没用上StallClock(middleware/retry.py:92-131)把真实尝试的耗时从 stall 账里扣掉了,回归测试test_single_timeout_does_not_exhaust_stall_budget正是这个场景latency_ms7–74ms,cost_usd=0backends/memory/breaker.py在 1.0.6 与 1.2.1 之间逐字节相同(md5 两边均为630ed36ddeb87e08a9bac58260056046)本 issue 只诉求第二行。
1. 问题的准确形态
1.1 单源时熔断的语义已经变了,而库没有承认这一点
熔断的设计前提是"这个源坏了,把流量导到别的源"。我们的 scope 只有一个源
(第三方中转,配不出第二个)。这个前提在我们这里不成立,于是同一段代码做的事变成了:
"这个源坏了,所以整个 scope 停止服务"。
具体路径(1.2.1 源码,行号按 HEAD):
middleware/retry.py:319-332——_pick_runnable逐个候选源过闸。源在冷却备忘里(
SourceCooldownMemo)或熔断门返回allowed=False时,gate_rejections += 1,一个网络包都不发。
middleware/retry.py:370-379——_on_no_runnable看到gate_rejections == len(self._sources)当场抛
CircuitOpenError。单源时这个等式恒等于"唯一那个源被拒了",多源时它才有"全都坏了"的含义。
结果:开路期间进来的每一次调用都在几毫秒内死掉,
max_attempts一格没动,账上是零成本零信息的失败。我们实测的 30 次里,
latency_ms最小 7ms。1.2 触发条件比想象的容易得多
现场配置:
MAX_CONCURRENCY=2、TIMEOUT_S=300、FAIL_THRESHOLD=20、COOLDOWN_S=60、MIN_CALLS=20、FAIL_RATE=0.6、WINDOW_S=60。失败率通道(
backends/memory/breaker.py:92-97)的判据是"窗口样本 ≥ min_calls 且失败率 ≥ fail_rate"。中转回 503 时失败得非常快(实测 0.6–1.2s/次),所以样本攒得比正常流量快一个量级:
中转抖 36 秒,就够把一条跑了 3 小时 18 分钟的实验臂打断。
按库缺省的
min_calls=10只需要一半时间。1.3 半开探针让并发调用方的处境更糟(这一条是读代码读出来的,本次事故里没触发)
开路冷却到期后进入 HALF_OPEN,只放一个探针进去(
backends/memory/breaker.py:145-156)。其余并发调用方拿到的
allowed=False,retry_after_s是探针租约的剩余时长,而
probe_ttl_s的派生规则是max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)(
config.py:367-376)。我们的TIMEOUT_S=300⇒probe_ttl_s = 600。于是单源 scope 下会出现这种组合:冷却期只有 60 秒,但半开期间被拒的调用被告知"等 600 秒"。
业务侧照
retry_after_s延期重投的话,600 秒往往已经超出它愿意等的上限,只能放弃。2. 为什么这是库该解决的,而不是下游该绕开的
2.1 现有的三条绕法我们都查过,没有一条是干净的
PGW_BREAKER_BACKEND挑一个空实现config.py:55的_BREAKER_BACKENDS = frozenset({"memory", "redis"}),没有noop档FAIL_THRESHOLD设成极大值min_calls/fail_rate/window_s)与它完全独立,我们这次正是被失败率通道熔的FAIL_THRESHOLD+MIN_CALLS双双设成极大值_rate_channel_open在attempts < min_calls时直接return False),但这是把两个阈值参数当开关用,语义靠猜;而且fail_threshold会被config.py:366的max(配置值, 源级并发×2)悄悄改写,"我配的是多少"和"实际是多少"本来就不是一回事而且这条绕法关不掉
force_open:record_failure在force_open=True(SourceDeadError,来自 401/403 与
insufficient_quota的 429)时直接_open,一击即熔,不看任何阈值(
backends/memory/breaker.py:230-233)。经中转部署时 401/403 也可能来自中转自己。2.2
errors.py那句"业务侧 catch 本类做延期重投",本身就是把一件属于库的职责推给了调用方我们曾经照这句话在下游实现了一层延期重投(catch
GatewayUnavailableError、按retry_after_s等满再发一次),随后把它整个删掉了,理由是架构层面的:
重试、退避、延期、换源属于网关的职责。调用方再写一层,就是同一件事在两个地方各做一份。
两边的口径将来必然漂移——库调了退避曲线、下游那层不知道;下游改了等待上限、库的遥测算不进去——
而漂移之后,"这次调用到底等了多久、试了几次"没有任何单一事实源答得出来。
对一个要把调用次数与墙钟写进论文的下游来说,这不是洁癖,是数据可信度问题。
即便调用方肯写这一层,它也做不好,原因全在库这边:
retry_after_s在半开期给的是探针租约(可达 600s,见 §1.3),而它是从TIMEOUT_S派生的,与"这个源多久能恢复"没有任何因果关系——照它等,等的是一个物理上无意义的数。
cooldown_s × 2^(重开次数-1)指数递增(_cooldown_eff),单源下"重开"几乎必然连续发生,等待时间迅速逼近
max_cooldown_s(缺省 300s)。调用方要么等满,要么在库尚未恢复时放弃。所以我们对这条契约的诉求是:把它收回库内。 无论采纳下面哪个方案,
建议同时修订
errors.py那句 docstring——它现在鼓励每一个下游各自实现一份重试逻辑。2.3 补记:那一层删掉之后,代价已经量化出来了
上面说的删除我们已经做完了,随之而来的后果是可测的,写在这里让"库该不该扛住"这个判断有个数:
熔断打在门控批上,代价是整条实验臂当场停、要人来续跑。 门控批要求一批题全部评得出分才能
与参照分比大小(少几道题去比大小等于悄悄换了比较口径),所以没评上分的尝试会被作废、
这一轮失败退出。我们的全量级门控批是 57 道题,也就是说中转抖 36 秒(实测:双 30 秒桶内
22 次尝试 / 19 次 HTTP 503 = 86.4%,够到
fail_rate阈值)就足以让一条已经跑了几小时的臂停下来。做题批的代价则是数据变薄:死于熔断的 rollout 不再被送去评分(否则它会拿到一个 0 分,
与"真的做错"在下游完全同形,进而压低门控分、进反思材料池、被判定器判成失败),
于是那一轮的反思材料条数如实变少。这是对的处置,但它意味着每一次熔断都在直接改变实验的输入。
这两条都不是"下游没处理好"——它们正是在处理好之后剩下的代价。
调用方能做的到此为止,再往下就只有库能决定"开路期间是当场判死还是排队等"。
核心不对称:多源 scope 里熔断的代价是"少一个源",单源 scope 里熔断的代价是"整个 scope 停服"。
代价差了一个数量级,而库对这两种情形用的是同一套默认值、且不允许调用方表达自己属于哪一种。
2.3 顺带说明:这不是 issue #8 的残留,但它俩是同一个病根的两侧
issue #8 修的是"生产性时间被算进 stall 账"。修完之后仍然成立的是:
被限流闸挡在门外、一次尝试都没发出的调用,其
max_attempts依然一格都用不上——因为它烧的是 stall 账(
tests/unit/test_backpressure.py::TestStallQuadrants::test_both_windows_exceeded_raises_stalled钉的正是这个行为,
per_source_reasons == {"s1": "rate_limited"})。这是设计选择、不是 bug,我们不诉求改它;提一句是因为它和本 issue 合起来解释了
同一次中转故障为什么会从两个方向同时打穿单源 scope。
3. 可能的方案与各自代价
按我们的偏好排序,但选哪条由维护方定。
方案 A:给熔断一个显式的关闭档
新增
PGW_BREAKER_BACKEND=noop(或{SCOPE}__BREAKER__ENABLED=false),装配一个恒
allowed=True、写回全部 no-op 的门。NullGate类),不动任何既有代码路径,
GateDecision契约原样。这个代价必须写进文档,否则会有人在多源场景下也关掉它。
noop而不是off)与文档警示缓解。方案 B:让"开路时怎么办"可配置,而不是只能 fail-fast
仿照已有的
{SCOPE}__QUOTA_FULL=wait|fail_fast,新增{SCOPE}__CIRCUIT_OPEN=fail_fast|wait。取
wait时_on_no_runnable不抛CircuitOpenError,而是走既有的 poll 轮询分支,由 stall 预算兜底(冷却轮询本来就已经被归类为非生产性等待,账目现成)。
改变的只是"调用方是当场死还是排队等"。这恰好是单源与多源的真正分界:
多源时 fail-fast 是对的(换源更快),单源时没有源可换,等待才是对的。
与
quota_full同构,学习成本接近零。"请先读这一条"待遇。半开探针在途时的等待同样需要覆盖,否则 §1.3 那条还在。
wait档下若冷却指数递增到max_cooldown_s=300,调用会等满 300s 才判死,与直接 fail-fast 相比只是把成本从"失败率"挪到"墙钟"。需要在文档里写清这不是免费的。
方案 C:只修半开探针在单源下的
retry_after_s把 HALF_OPEN 拒绝时的
retry_after_s从"探针租约剩余"改成一个与恢复时间有因果关系的值(例如
min(probe_expires - now, cooldown_s))。方案 D:什么都不改,写进文档
在 README 明确写"单源 scope 下熔断等于整体停服,请把
FAIL_THRESHOLD与MIN_CALLS同时设为极大值来实质关闭它,并注意
force_open不受此影响"。且它解决不了
force_open。我们认为这条比 A 差,但如果维护方判断熔断的默认值不该被削弱,它至少让下游不必靠读源码才能活下来。
4. 我们这边的复现材料
runs/_gateway_telemetry.db(sqlite,llm_calls表,created_at between '2026-08-19 11:06:20' and '2026-08-19 11:08:16'即完整现场)runs/evo-center-b-m3-s0-t0_6/metrics.dbminimax,MAX_CONCURRENCY=2、RPM=45、TIMEOUT_S=300、MAX_ATTEMPTS=8、BACKOFF_BASE_S=1、BACKOFF_MAX_S=120、FAIL_THRESHOLD=20、COOLDOWN_S=60、MIN_CALLS=20、FAIL_RATE=0.6、WINDOW_S=60需要哪一段原始数据我们随时可以导出。
已在 1.2.4 修复并发布。感谢这份 issue——它的复核质量让讨论可以直接从"修什么"开始,而不是先花几轮确认现象。
你们要做的
一行。另外
requirements.txt里钉的是polygateway>=1.0.6,<1.1,需要放开到>=1.2.4,<2才拿得到。采纳了 B + C,但两者不是并列关系
方案 B(
{SCOPE}__CIRCUIT_OPEN=fail_fast|wait)是主干,与QUOTA_FULL逐项同形。但 C 不是你们说的"可以并行做的治标"——它是 B 的前提:wait档要按retry_after_s睡,而那个值在半开时是探针租约剩余(你们算得没错,TIMEOUT_S=300时是 600s)。不先修它,wait档就会照着一个物理上无意义的数去睡,冷却 60 秒就结束了却要多躺 540 秒——那是拿一个新事故换旧事故。A 和 D 都没采纳。A(
noop后端)关掉的是保护:401/403/配额耗尽的一击即熔会一并失效,库会拿着坏密钥持续撞墙;而你们要的是"别当场判死",不是"别熔断"。它还给库开了"治理组件可以整个关掉"的先例,限流迟早也会有人要关。D 会把"两个阈值同设极大值"这个配置副作用固化成公开契约,将来一动阈值逻辑就破坏它,而且如你们所说它关不掉force_open。§1.3 比你们判断的更重,而且不改配置也受益
你们把半开那条标为"读代码读出来的,本次事故里没触发",诉求只落在"下游被告知等 600 秒"。实际后果在库内,比对外报错的数字重得多:
那个 600 被喂进了源冷却备忘(
retry.py:354),而SourceCooldownMemo.set_until取更晚者、不可回退。于是——源开路 → 冷却到期 → 调用①拿到探针 → 并发的调用②被拒并给该源记下 600 秒本地冷却 → 调用①的探针成功、门恢复CLOSED→ 本进程此后仍然跳过这个健康的源将近 10 分钟。单源下每次调用照旧抛CircuitOpenError。实测(
InMemoryGate+ 注入时钟,cooldown_s=60、probe_ttl_s=600):这条与单源无关——多源部署里任何一个源每开路一次,就会被该进程从可用池里除名将近
2 × TIMEOUT_S,只是别的源接住了流量所以从来没人报过。你们那 30 次瞬死里有多少来自这一条无法反推,但机制确凿。修法是把
retry_after_s的语义定死为「距离确定可再试的时刻还有多久」:OPEN→ 剩余冷却,HALF_OPEN→0.0(探针随时可能出结果,不存在确定时刻),准入允许 →0.0。备忘于是写入一个已过期的时刻,回归"只记开路的确定冷却期"。这部分在缺省fail_fast档下同样生效,所以即使你们暂时不改配置,升级本身就修掉了它。同批还统一了两个后端在六个出口上的口径,其中四处是既有的 memory/redis 分叉(redis 在授予探针时返回探针 TTL、写回被 fencing 拒时返回租约剩余,而 memory 一直返回 0)。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。
wait档的代价,说清楚保护完整保留:等待期间一个请求都不发,不烧配额不烧钱。改变的只是调用方当场死还是排队等。
{SCOPE}__BACKPRESSURE__STALL_WINDOW_S(缺省 300s)。等待按冷却截止睡,不是按poll_interval空转(60 秒冷却用 50ms 轮询是 1200 次 Redis 往返 × 每个在途调用)。wait不豁免重试预算:冷却结束后放行的探针是一次真实尝试,失败照样烧一格MAX_ATTEMPTS。所以密钥失效(401/403)这类一击即熔的源通常更早以reason=retry_exhausted结束,而不是等满窗口的stalled——哪个先到取决于MAX_ATTEMPTS与冷却时长、STALL_WINDOW_S的相对大小。(这条是我们自己的整分支审查揪出来的:初稿文档写成了"一律等满 300 秒",与代码不符,已更正。)MAX_ATTEMPTS=8、COOLDOWN_S=60、STALL_WINDOW_S缺省 300 的组合下,冷却指数递增(60/120/240…)会先吃光 stall 预算,所以更可能落在stalled那一侧。§2.2 那条契约也改了
GatewayUnavailableError的 docstring 重写了。原文"业务侧 catch 本类做延期重投"确实读起来像在鼓励每个下游各写一份重试逻辑。现在写明:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于任务级重试,语义不同,由业务自行在库外包。你们删掉那一层是对的。一处判断分歧,说明一下
我们没有按"单源特判"来修。
if len(sources) == 1会让行为随池大小突变、两条分支要分别测试维护,是比现状更重的债。真正缺的是准入策略矩阵里的一格——限流拒绝有wait/fail_fast两档,熔断拒绝只有一档。补上这一格之后,单源和多源走同一套逻辑,库不需要知道自己有几个源。这也意味着多源部署遇到"所有源同时开路"(共同上游挂掉、全网抖动)时同样受益,只是概率低而已。验证:全套件 980 passed,Redis 时间语义 18 个真实等待变体全绿(19 分 11 秒,不缩放时长),覆盖率 94%。设计与决策论证见
research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md。升级后如果
wait档的等待时长或失败 reason 与预期不符,欢迎带遥测数据再开 issue。