重试只有一个主人:任务层
上级:events
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
重试只有一个主人:任务层。handler 只负责把失败分类,什么时候再试由声明在任务种类上的策略决定。
之前的多层重试 vs 之后
3 × 3 × 3 = 一次失败打出过 27 个请求。现在只有任务层重试:
internal/infra/retry和notifyPolicy已删除。门禁check-retry-only-in-jobs.sh禁止retry.Do出现在internal/infra/jobs之外(见 no-bypass-by-structure)。- webhook 投递用带
NoRetry的httpx。 - Meili 客户端自带的重试已关掉(
meilisearch.DisableRetries()),改由corpus.index任务重试。
同步调用只发一次
访客在等的调用(日历忙闲、经 openapi 适配器的插入和删除)不再在请求里跑 retry.Do。暂时性失败立刻映射为 ErrCalendarUnavailable。理由:重试只有一个主人,面向访客的调用要快速回答。真正需要重试的(比如补偿性删除日历事件)是持久的 supplier.invoke 任务。
失败分类:handler 返回什么
- 4xx 除 408 / 429 外一律视为永久性:再试也不会好,只会刷对方。
- snooze 不消耗重试次数。webhook 按
Retry-After的 snooze 最长 10 小时。 - 在 River 上,
jobs.Discard(err)是带错误的取消,显示为discarded;面板里手动取消显示为cancelled。
策略(声明在任务种类上,是数据)
| 种类 | 最多尝试 | 退避 | 耗尽后 |
|---|---|---|---|
corpus.index、corpus.reindex | 10 | DefaultBackoff:1 秒起翻倍,封顶 15 分钟 | discarded + 告警;面板可重试 |
owner.notify、access_request.approval_mail、owner.email_confirmation | 8 | 20 秒起,每次 ×3:共约 6 小时 | discarded + 告警(告警走面板,不再发邮件) |
webhook.fanout | 10 | DefaultBackoff | discarded + 告警 |
webhook.deliver | 18 | Svix:5s · 5m · 30m · 2h · 5h · 10h · 10h…(约 5.3 天) | discarded;端点连续失败 5 天则停用 |
| 周期任务 | 不重试 | 等下一个周期 | 面板显示上次失败 |
所有退避都加 ±10% 抖动,避免一批同时失败的任务同一时刻一起重来。
对同一个挂掉的端点不要狂敲
- 任何非成功都会设
failing_since(410 和 429 也算);一次成功就清掉。 - 每个端点同时只有一次投递,靠一行租约(
busy_until);见 concurrency-control。
有副作用的重试:会不会发两次
- webhook: 每次重试带同一个
webhook-id(= 事件 id),消费方按它去重。见 webhooks。 - 邮件: SMTP 没有幂等键。顺序是“先发送,再标记”(
notified_at、replied,或删掉预约通知行),发完还没标记就崩,重试会多发一封。这是至少一次的代价,远比“失败即丢”好;邮件带Message-ID<事件 id@standmeet>,多数邮箱会把同 ID 的重复合并。 - 邮件限流: 每个收件人的额度(每小时 30 封)用完就 snooze 到下个窗口,不丢。给 owner 的通知突发上限(每个 owner 每小时 5 封)仍是有意丢弃;见 message-loss-guarantees。
- 搜索索引: upsert 天然幂等。
手动重试
tasks-panel 支持单条重试;webhooks.redeliver 在对方修好之后把这个端点所有 discarded 的投递重投一遍。已覆盖:Phase 2 的“sink 连返 500 两次”、“返回 429 + Retry-After → 稍后重试且不计次数”、“返回 410 → 立即 discarded”。
相关:message-loss-guarantees · saturation-degrades-gracefully · concurrency-control