Webhook 重试调度器「该等的没等够、该发的被误判成发太早」:draining 端点泄漏 + dispatch_gap 当上限 + 退避指数用累计次数 + 成功后计数不归零 + 空判据表健康检查假绿
日期:2026-10-22 | 分支
2026-10-22-1601(第 140 题)| 难度:中 关键词:Webhook 重试调度 / 指数退避(exponential backoff)/ 最小重试间隔 dispatch_gap / 累计尝试次数 max_attempts / 投递边界 delivery boundary / draining 摘流端点 / 死信 dead letter / 健康检查假绿 / 空判据表 / 退出码分层 fail-closed / 外部独立探针 / Docker Compose 复现 技术栈:Python 3.11 标准库 + TSV 台账(endpoints.tsv/attempts.tsv)+ JSON 报告 + Shell 验收脚本 + Docker Compose
一、问题背景
一套对外 Webhook 投递系统的重试调度器:每次投递失败要按指数退避安排下一次重试,重试不能太密(会打到对方限流),也不能超过端点的累计尝试上限(超了就该进死信);同时在**摘流(draining)**的端点上不许再派新尝试。
上线后合作方投诉方向完全相反:
- 有的端点「重试来得太快」——准入判定太宽松;
- 有的端点「第一次失败后直接进死信」——准入判定太严格。
而调度器自己一切正常:run.py 退出码 0,报告 ok: true,python3 -m app.health 输出 {"degraded": false, "issues": []} 且 rc=0。四个绿灯,但没有一个指向真相。
唯一说真话的是外部独立探针 client/dispatch_probe.py(只读契约 + 端点清单 + 台账 + 报告,不采信报告里任何自述布尔值):
RESULT: BAD(不一致 26 条)
- admissible_endpoints:期望 ['ep-alpha', 'ep-bravo', 'ep-charlie'] 实际 ['ep-alpha', 'ep-bravo', 'ep-charlie', 'ep-delta']
- a-02.earliest_ts:期望 1017 实际 1077
- a-04.earliest_ts:期望 0 实际 1217
- totals.backoff_s:期望 171 实际 143
- counts.skipped:期望 1 实际 None
- ok:期望 False 实际 True
bash ops/verify.sh 问题态:PASS=9 FAIL=23 / RESULT: BAD。
二、复现步骤
# 1) 起环境(app/ 是 COPY 进镜像、无 bind mount,改完必须 --build)
cd scenario/2026-10-22-1601
docker compose up -d --build
docker compose exec -T dispatcher bash # 非交互必须加 -T
# 2) 复现问题态
cd /srv
bash ops/repro.sh ; echo "rc=$?" # RESULT: BAD(不一致 26 条),rc=1(这是「复现成功」的信号)
bash ops/verify.sh | tail -3 # PASS=9 FAIL=23 / RESULT: BAD
python3 run.py # 自述 rc=0,plan=10 endpoints=4 too_early=6
python3 -m app.health ; echo "rc=$?" # {"degraded": false, "issues": []},rc=0(假绿)
# 3) 修完后重建再验
docker compose up -d --build
docker compose exec -T dispatcher bash -c 'cd /srv
bash ops/repro.sh | tail -2 # RESULT: OK / probe rc=0
bash ops/verify.sh | tail -2 # PASS=32 FAIL=0 / RESULT: OK
bash ops/negative.sh | tail -3 # NPASS=11 NFAIL=0 / RESULT: OK(闸门有牙)
'
权威口径在 docs/retry-contract.md,与代码不符即为 bug。
三、根本原因
数据集(4 个端点、10 条尝试):
endpoints.tsv
ep-alpha active 3 10 70
ep-bravo active 2 20 100
ep-charlie active 2 40 120
ep-delta draining 3 10 60 ← 摘流中,本轮不得派发
attempts.tsv
a-01 ep-alpha 1 1000 1002 502 ...
a-02 ep-alpha 1 1030 1031 503 ... ← attempt_no 从 1 重新开始 = 同一条投递的第 2 次
a-03 ep-alpha 2 1050 1052 504 ...
a-04 ep-alpha 1 1080 1080 200 ... ← 成功,投递结束
a-05 ep-bravo 1 2000 2001 - connect_timeout
a-06 ep-bravo 2 2080 2085 201 ...
a-07 ep-charlie 1 3000 3005 500
a-08 ep-charlie 2 3050 3060 500
a-09 ep-delta 1 4000 4001 500
a-10 ep-alpha 1 1120 1121 502 ... ← a-04 成功之后的新投递,第一次尝试
根因 1(题眼)draining 端点泄漏进可调度集合
def candidates(endpoints):
return [e for e in endpoints if e["state"] != "disabled"] # ✗ 只排除了 disabled
契约 §1.1:state ∈ {active, draining, disabled},只有 active 可调度;draining 正在摘流,本轮不得再派新尝试。
黑名单写法(排除已知的坏状态)天生会漏;应该用白名单:state == "active"。
后果:ep-delta 的 a-09 进 plan → totals.attempts 10(应 9)、totals.endpoints 4(应 3)。
根因 2(题眼)dispatch_gap_s 被当成上限,用了 max 而不是 min
def backoff_delay(cfg, ep, attempt_no, attempts_so_far):
delay = ep["backoff_base"] * (cfg["WEBHOOK_BACKOFF_FACTOR"] ** attempts_so_far)
return max(ep["dispatch_gap_s"], delay) # ✗ gap 是下限,该取 min
契约 §2.3:
earliest_ts = finish_ts(上一次) + min( dispatch_gap_s, backoff_base * FACTOR ** (attempt_no - 1) ) + ADMIT_SLACK_S
dispatch_gap_s 是「相邻两次尝试之间的最小间隔」→ 它是下限,min 表示「退避可能比 gap 更大,但至少隔 gap」。
写成 max 就变成「永远按 gap 走」,退避基数 backoff_base 只在超过 gap 时才生效,退避语义整个反过来。
后果:a-02 期望 1002 + min(70,10) + 5 = 1017,实际 1002 + max(70,10) + 5 = 1077 —— 「该等的没等够」的一半来源。
根因 3(题眼)退避指数用了「累计次数」而不是「投递内序号」
seen = counter.get(a["endpoint_id"], 0) + 1 # 该端点在整个台账里的第几行
earliest = prev_finish + backoff_delay(cfg, ep, a["attempt_no"], seen) + slack
# ^^^^ 传的是累计次数
契约 §2.3 明写「退避的指数按投递内序号 attempt_no 计算,不是全局/累计次数」。
而契约 §1.2 又特意提醒:「同一投递在失败后重发时,attempt_no 会从 1 重新开始」。
两个提示合起来才是完整语义:指数看 attempt_no,投递边界看「成功 / 达上限」,两者都不能用「累计次数」代替。
后果:a-03 期望 1031 + min(70, 10*2^1) + 5 = 1056,实际用了 2^3=8 → 退避 80 → 1031+70+5=1106(max 叠加后 1091)。
根因 4(题眼)成功之后累计计数没有归零 → 新投递被当成「继续重试」
seen = counter.get(a["endpoint_id"], 0) + 1
counter[a["endpoint_id"]] = seen
...
if a["attempt_no"] == 1 and seen == 1:
earliest = 0
else:
earliest = prev_finish + ...
counter 从不清零,last_finish 也跨投递沿用。a-04 是一次成功,契约 §2.2 说「任意一行判定为成功,即视为该投递结束,后续行属于新的投递、计数从零重新开始」。
于是一次本该 earliest_ts = 0 的全新投递首投,被算成了「接着上一条重试」:
a-04期望earliest_ts = 0,实际1080 + 130 + 7之类的垃圾值1217;a-10期望0,实际1405;- 更狠的是
a-10因此scheduled_delta_s变负 → 被误判too_early,「该发的被误判成发太早」。
这是本题最核心的一处:attempt_no 不能用来判定投递边界(它会从 1 重来),必须自己按「成功 / 达上限」切分。
根因 5(连带)终态统计漏了 skipped、汇总字段全部拍脑袋
out = {e["endpoint_id"]: "exhausted" for e in endpoints} # 全部先当 exhausted
for a in attempts:
if 200 <= status < 300: out[...] = "succeeded" # ✗ 只看「曾经成功过」,不看最后一条
...
totals: attempts = len(attempts) # ✗ 应为 len(plan)
endpoints = len(endpoints) # ✗ 应为 len(admissible)
over_dead_letter: False # ✗ 硬编码
ok: True # ✗ 硬编码
counts 只有 succeeded/exhausted/attempts # ✗ 缺 skipped
契约 §3:counts 按 final_state 统计端点个数,键固定 succeeded / exhausted / skipped,非 active 端点 → skipped。
后果:counts.skipped 缺键(探针报 期望 1 实际 None);a-04 成功但 a-10 又失败,ep-alpha 最后一条是失败 → 应为 exhausted,实现却报 succeeded。
根因 6(假绿)健康检查:空判据表 + 无条件 return 0
BAD_FIELDS = () # ✗ 空元组
...
for field in BAD_FIELDS: # 循环执行 0 次
if not report.get(field):
issues.append(field)
...
return EXIT_OK # ✗ 无论查出什么都返回 0
三重病:
- 判据表为空 →
issues恒空 →degraded恒false; - 退出码写死 → 即使真有 issue 也
return 0; - 完全不复用契约 → 报告缺失、台账读不到,都当健康。
degraded: false 只代表「没检查」,比没有健康检查更危险,因为它静默地骗人。
四、解决办法
只改 app/(planner.py / health.py / contract.py,另补 __init__.py);conf/ docs/ input/ client/ ops/ 一字未动。
1. 把「投递边界」抽成显式结构,所有计算复用它
def split_deliveries(attempts, endpoints, cfg):
"""给每条台账行标 (投递序号, 投递内序号)。
一条投递以「成功」或「累计达 max_attempts」结束;下一行起新投递、计数从 0 重来。"""
deliv_no, deliv_seq = {}, {}
count = {ep: 0 for ep in endpoints}
deliv = {ep: 0 for ep in endpoints}
for idx, att in enumerate(attempts):
ep = att["endpoint_id"]
if ep not in endpoints:
continue
if count[ep] == 0:
deliv[ep] += 1
elif count[ep] >= endpoint_limit(endpoints[ep], cfg):
count[ep] = 0
deliv[ep] += 1
count[ep] += 1
deliv_no[idx] = deliv[ep]
deliv_seq[idx] = count[ep]
if is_success(att, cfg): # ← 成功即投递结束,计数归零
count[ep] = 0
return deliv_no, deliv_seq
2. 白名单候选集 + min + 指数用 attempt_no
def candidates(endpoints):
return [e for e in endpoints.values() if e["state"] == "active"]
def backoff_delay(cfg, ep, att):
base = endpoint_base(ep, cfg)
delay = min(ep["dispatch_gap_s"], base * (cfg["WEBHOOK_BACKOFF_FACTOR"] ** (att["attempt_no"] - 1)))
return delay
def earliest_ts(ep, cfg, att, seq_in_delivery, prev_finish):
if seq_in_delivery == 1:
return 0 # 新投递的首投无需等待
return prev_finish + backoff_delay(cfg, ep, att) + cfg["WEBHOOK_ADMIT_SLACK_S"]
3. 汇总字段全部算出来
admissible = sorted(e["endpoint_id"] for e in candidates(endpoints))
too_early = sum(1 for p in plan if p["verdict"] == TOO_EARLY)
dead_letter = {} # 每端点 too_early 次数
over_dl = any(v > cfg["WEBHOOK_DEAD_LETTER_AFTER"] for v in dead_letter.values())
counts = {"attempts": len(attempts), "succeeded": 0, "exhausted": 0, "skipped": 0}
...
"totals": {"attempts": len(plan), "endpoints": len(admissible),
"too_early": too_early, "ok": len(plan) - too_early,
"backoff_s": sum(backoff.values())},
"ok": too_early == 0 and not over_dl, # 算出来的,不是写死的
4. 健康检查:独立重算 + 非空判据表 + 退出码分层
CHECKS = ("admissible_endpoints", "plan", "totals", "counts", "over_dead_letter", "ok")
def _facts(cfg): # 自己读 conf + endpoints.tsv + attempts.tsv 独立重算,不 import planner
...
def main():
try:
cfg = contract.load()
except contract.ContractError as exc:
_emit(["contract: %s" % exc]); return 3 # 契约不可用
try:
with open(REPORT) as fh: report = json.load(fh)
except (OSError, ValueError) as exc:
_emit(["report_unavailable: %s" % exc]); return 2 # 拿不到事实
issues = check_report(report, cfg)
_emit(issues)
return 0 if not issues else 2
check_report 里 if not CHECKS: return ["no_checks:判据表为空,判不了不等于没问题"] —— 判不了 ≠ 没问题。
五、验证
bash ops/repro.sh → RESULT: OK (rc 由 1 → 0)
bash ops/verify.sh → PASS=32 FAIL=0 / RESULT: OK
bash ops/negative.sh → NPASS=11 NFAIL=0 / RESULT: OK(闸门有牙)
修复后报告(与契约 §3 给的参考值逐项吻合):
{
"admissible_endpoints": ["ep-alpha","ep-bravo","ep-charlie"],
"totals": {"attempts": 9, "backoff_s": 171, "endpoints": 3, "ok": 7, "too_early": 2},
"counts": {"attempts": 10, "exhausted": 2, "skipped": 1, "succeeded": 1},
"over_dead_letter": false,
"ok": false
}
注意:正确答案里
ok就是false(本轮确实有 2 条too_early)——要修的是判定与统计,不是把ok强行弄绿。
手算锚点(确认自己不是「撞对答案」):
a-01 = 0 → delta 1000 ok
a-02 = finish(a-01) 1002 + min(70, 10*2^0)=10 + slack 5 = 1017 → delta 13 ok
a-03 = finish(a-02) 1031 + min(70, 10*2^1)=20 + 5 = 1056 → delta -6 too_early
a-04 = 0(成功那次本身属于该投递首投) → delta 1080 ok
a-06 = finish(a-05) 2001 + min(100, 20*2^1)=40 + 5 = 2046 → delta 34 ok
a-08 = finish(a-07) 3005 + min(120, 40*2^1)=80 + 5 = 3090 → delta -40 too_early
a-10 = 0(a-04 成功之后的新投递首投) → delta 1120 ok
total_backoff_s = ep-alpha (1050-1031) + (1120-1080)
+ ep-bravo (2080-2001)
+ ep-charlie (3050-3005)
= 19 + 40 + 79 + 45 = 171 ✓ 与 totals.backoff_s 一致
退出码分层手工验证(容器内):
| 场景 | run.py | app.health |
|---|---|---|
| 干净报告 | 0 | 0,degraded=false |
| 报告被篡改 / 报告缺失 | —— | 2 |
契约缺必填键 / 非整数 / MIN>MAX | 3 | 3 |
边界自查 ops-extra/custom-cases.py 4 个用例(gap 当下限、成功后归零、over_dead_letter、disabled 不进 plan)全部 RESULT: OK。
六、经验与避坑
6.1 五个判定口径,每个都值得单独背下来
- 状态用白名单,不用黑名单。
state != "disabled"一定会漏draining。凡是「只有某几种状态可用」,写== "active",不要写!= "bad1" and != "bad2"。 min还是max,先问「这个参数是下限还是上限」。 「最小间隔 / 最小退避 / 至少间隔 N 秒」全是下限,min的是「实际退避」「上限」。契约里出现min(a, b)时,a通常是被夹取的量、b是天花板;如果b的语义是「至少」,那就是min。- 指数退避的指数是「本次投递内的第几次」,不是「全局第几次」。 重试在新的一条投递里从 1 重来,这就是为什么退避不能一直涨到失控。
- 投递边界必须自己切,不能靠
attempt_no。attempt_no从 1 重来恰恰说明它不是单调的,用它切分或计数都会错。边界 = 「成功」或「累计达max_attempts」。 - 成功之后计数立刻归零。 少这一句,成功之后的每一次新投递首投都会被算成「接着上一条重试」,
earliest_ts变成非 0、delta变负、verdict被误判too_early—— 这就是「该发的被误判成发太早」的全部来源。
6.2 假绿的三种典型病(本题一次全中)
- 判据表为空:
BAD_FIELDS = ()/CHECKS = (),for执行 0 次,issues恒空。 自查:这个for真的执行了吗?把表打出来看长度。 - 退出码写死:
main()无条件return 0。算出问题也报绿。 自查:故意篡改报告一个字段,我的健康检查会转红吗? - 判不了当没问题:报告缺失 / 台账读不到 / 契约缺键,全部
return 0。 自查:「判不了」和「没问题」是不是同一个退出码?
正确的做法是退出码分层:0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用或口径非法(fail-closed)。
6.3 健康检查不得复用被测代码
契约 §4 明写「必须按本文独立重算(不得复用 app/planner.py 的输出)」。
复用被测代码 = 让它自证健康:bug 会被「一致地」复现,两边对上了 → 判健康。
所以 health.py 里另写了一份 _facts(),自己读 conf/retry.env + 两个 TSV,只依赖 contract.py(配置读取)。
6.4 本次最有价值的一个坑:负向闸门是「字符串补丁」,它会空转
ops/negative.sh 的做法是逐个把题眼用 str.replace 改回去,然后跑探针,要求探针转红。
我第一版写法比较「干净」,把退避计算抽成 backoff_delay(cfg, ep, attempt_no),一行 min(...) 搞定。
结果:
PASS N1 (draining)
FAIL N2 探针 rc 期望 1 实际 0 ← max 补丁没打上
FAIL N3 探针 rc 期望 1 实际 0 ← 指数补丁没打上
PASS N4 (成功不归零)
FAIL N5 探针 rc 期望 1 实际 0 ← 首投起点补丁没打上
NPASS=8 NFAIL=3 / RESULT: BAD(闸门没牙)
原因:脚本替换的是 ' delay = min('、'cfg["WEBHOOK_BACKOFF_FACTOR"] ** (att["attempt_no"] - 1))'、
' if seq_in_delivery == 1:\n return 0\n' 这些具体字面。
我的实现里:
min(前面有delay =但整行是delay = min(ep["dispatch_gap_s"], endpoint_base(ep, cfg) * (...)),... (attempt_no - 1))结尾 —— 变量名不是att["attempt_no"];- 首投的
if我写在了build_plan内联的if deliv_seq[idx] == 1:,不是if seq_in_delivery == 1: return 0。
补丁一个都没匹配上,文件一字未改,探针当然还是绿的。
定位方法(值得记住):
# 在容器里原样跑一遍补丁,看源码到底有没有变
docker compose exec -T dispatcher bash -c 'cd /srv
cp app/planner.py /tmp/p.py
python3 - <<"PY"
s = open("/tmp/p.py").read()
print("found:", "cfg[\"WEBHOOK_BACKOFF_FACTOR\"] ** (attempt_no - 1))" in s)
PY'
# 再手工注入同样的改动 + python3 run.py + 跑探针,看会不会转红
手工注入能打出 不一致 11 条 → 证明「闸门有牙,只是没被触发」。于是改实现(不是改考卷),让每个题眼落在一个可被单行替换的表达上,三轮迭代后 11/0 全绿:
- 拆出
base = endpoint_base(ep, cfg),让min(...)那一行以att["attempt_no"] - 1))收尾; backoff_delay收整条att而不是attempt_no;- 把
earliest的计算搬进独立的earliest_ts(),让首投判定成为if seq_in_delivery == 1: return 0。
教训:实现可以有自由度,但每个「题眼」必须落在一个可被单行替换的位置。把判定摊进无害的辅助函数、改个参数名,负向闸门就静默空转,而你以为它是绿的。反过来说,看到 negative.sh 报「闸门没牙」时,先分清是「实现还有 bug」还是「补丁没打上」 —— 前者改代码,后者只能改代码的形状(改 ops/ = 改考卷 = 造假)。
6.5 Docker / 复现相关的坑
app/是COPY进镜像、compose 无 bind mount → 改完必须docker compose up -d --build,否则exec进去跑的还是旧代码,现象一点不变。docker compose exec非交互必须带-T(compose 配了tty: true)。ops/repro.sh退出码 = 探针退出码,问题态是1,那是「复现成功」的信号,用; echo rc=$?接住,别用&&串后续命令。ops-extra/不在镜像里(Dockerfile只COPY了app conf docs client ops input run.py),容器内直接跑会can't open file '/srv/ops-extra/custom-cases.py',要docker compose cp ops-extra dispatcher:/srv/ops-extra。ops/negative.sh会临时把app/改坏再还原(备份在/tmp/app.bak.$$),中途 Ctrl-C 会留下问题态源码,跑前git status确认干净。__pycache__按秒级 mtime 判过期,同一秒内改回源码可能继续用旧字节码 ——ops/lib.sh::clean_pyc已内置,手工跑要自己清(或python3 -B)。ops/expected-fixed/是参考解,只用来对照思路,别整目录覆盖app/。
6.6 自查口诀
只有 active 可调度吗?
min还是max、这个参数是下限还是上限?退避指数用的是投递内第几次,还是全局累计?投递边界我是自己切的,还是靠attempt_no猜的?成功之后计数归零了吗?totals.attempts数的是 plan 还是台账?counts有skipped吗?ok是算出来的还是写死的?判据表是不是空的、那个for真的执行了吗?「判不了」和「没问题」是同一个退出码吗?健康检查是不是在拿被测代码的输出校验它自己?把每个题眼逐一回滚,我的闸门会不会每一次都转红?
6.7 同类影响面
- 一切带指数退避的重试/补偿系统:HTTP 重试、消息队列消费重试、任务调度器、DB 重连、
Retry-After处理、K8sbackoffLimit、gRPC 重试策略 —— 都会遇到「最小间隔是下限」「指数按投递内序号」「成功后计数归零」这三件事。 - 一切**「累计上限 + 分轮次」的计数**:
max_attempts/backoffLimit/ 死信阈值 / 熔断半开探测次数 —— 分轮次时计数必须归零,attempt_no不能当单调序号用。 - 一切有摘流/下线状态机的调度:K8s Pod
Terminating、节点NotReady/cordon、LBdraining、服务实例preStop—— 判定必须用白名单== "active",「不可调度」的状态只会越加越多。 - 一切带自述
ok字段的报表 + 空判据表健康检查 + 退出码恒 0 的组合(这类比没有检查更危险,因为静默骗人)。 - 一切**「判不了就用默认值继续跑」的 fail-open 配置加载**(本题
contract.py原本就是 fail-closed,属少数做对的;不会做错的方向是加DEFAULTS回退表)。 - 一切用字符串
replace实现「回滚缺陷」的负向测试闸门(mutation testing 的手工版)—— 注入不生效时闸门看着是绿的,必须验证补丁真的落地了。
