重试只有一个主人:任务层

更新于 · 在 sijie.xyz 查看原条目 ↗

状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。

重试只有一个主人:任务层。handler 只负责把失败分类,什么时候再试由声明在任务种类上的策略决定。

之前的多层重试 vs 之后

flowchart LR
  subgraph today["Before: three layers each retried, failures multiplied"]
    direction TB
    g[invoke_background · retry.Do] --> m[mail_retry · notifyPolicy] --> h[httpx · auto-retries 5xx/429]
  end
  subgraph after["After: only the job layer retries"]
    direction TB
    j[job layer · backoff per policy + jitter] --> k[handler · only classifies the failure] --> n[httpx · NoRetry]
  end
  today ==>|consolidate| after

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 返回什么

stateDiagram-v2
  [*] --> running
  running --> completed : nil
  running --> retryable : any other error (timeout · connection failure · 5xx · 408 · 429 without Retry-After)
  running --> snoozed : jobs.Snooze (429 / 503 + Retry-After · endpoint busy · mail cap spent)
  running --> discarded : jobs.Discard (other 4xx · SSRF-blocked URL · endpoint disabled or gone · event pruned)
  retryable --> running : when due (backoff + jitter)
  snoozed --> running : at the time named, no attempt spent
  retryable --> discarded : attempts exhausted
  discarded --> running : owner clicks retry in the panel
  completed --> [*]
  discarded --> [*]
  • 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% 抖动,避免一批同时失败的任务同一时刻一起重来。

对同一个挂掉的端点不要狂敲

sequenceDiagram
  participant F as webhook.fanout
  participant Q as queue
  participant WD as webhook.deliver
  participant E as endpoint (down)
  WD->>E: deliver event 1
  E--xWD: 503
  WD->>Q: event 1 retryable, endpoint failing_since set
  F->>Q: events 2, 3… enqueued 5 min out (cooldown)
  Q->>WD: event 1 due on its backoff
  WD->>E: deliver event 1
  E-->>WD: 200
  WD->>Q: failing_since cleared, new deliveries run at once again
  • 任何非成功都会设 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