すべての記事
webhook designidempotencyretriesdistributed systems

Webhookのリトライと冪等性を涙なしで

主要なWebhookプロバイダー — Stripe、GitHub、Shopify、Twilio、Slack — はどれも、わざと同じイベントを複数回届けます。これをat-least-once delivery(少なくとも1回の配信)と呼びます。ハンドラがそれを想定していないと、二重課金された顧客、重複したGitHubのPRラベル、2回発火したSlack通知が生まれます。修正は小さく、過小評価されています。すべてのハンドラを、繰り返し配信に対して安全になるよう設計しましょう。

なぜ重複が起きるのか

プロバイダーは、時間内にきれいな2xxを受け取れないとリトライします。リトライ期間はさまざまです — Stripe: 指数バックオフで最大3日、GitHub: 約8時間で8回、Shopify: 48時間。たいてい元の配信は成功していて、ネットワークが帰りの応答を失っただけです。プロバイダーはそれを知りません。だからリトライします。だからハンドラが2回走ります。

これは直すべきバグではありません。システムの性質です。そのつもりで作りましょう。

実際に効く冪等性パターン

主要プロバイダーの各イベントには一意のイベントIDが含まれます。Stripe: evt_xxx。GitHub: X-GitHub-Deliveryヘッダー(UUID)。Shopify: X-Shopify-Webhook-Id。イベントを初めて処理するときにIDを保存します。リトライ時にそれを照合し — もう見たIDなら、ハンドラを再実行せずに200を返します。

async function handleWebhook(event) {
  const eventId = event.id;

  // Atomic insert-if-not-exists
  const inserted = await db.processedEvents.insertIfNew({
    eventId,
    receivedAt: new Date(),
  });

  if (!inserted) {
    // We've handled this one. Be polite about it.
    return { status: 200, body: 'already processed' };
  }

  // First time we've seen it. Do the work.
  await actuallyHandleEvent(event);

  return { status: 200 };
}

重要な形: insertとハンドラのロジックは一緒に原子的であるか、insertが先でなければなりません。先にhandleEventを呼んでからIDを挿入すると、ハンドラの途中でクラッシュした場合に記録が残らず、リトライが再処理します。

重複排除キーをどこに保存するか

  • ビジネス書き込みと同じDBトランザクション。 最良。重複排除キーと副作用が両方コミットされるか、どちらもされないか。イベントIDの列に一意制約を付けます。
  • 長いTTLのRedis。 重要度の低いイベントには十分。TTLは統合する各プロバイダーの最長リトライ期間に合わせ — 7日から始めます。
  • インメモリキャッシュ。 やめましょう。プロセスが再起動するとキャッシュは消え、次のリトライがすでに走ったハンドラの再実行に成功してしまいます。

「冪等でない」が実際にどう見えるか

私たちが遭遇した実例: account.credit(amount)を直接呼ぶStripeのinvoice.payment_succeededハンドラ。成功した支払いごとに残高が増えました。たいていはちょうど1回。リトライが同じイベントを捕まえたときに時々2回。ある顧客が、なぜ50ドルの請求が口座に100ドルを入金したのかと丁寧にメールで尋ねてきて発覚しました。

修正はStripeのリトライポリシーを変えることでも、プロバイダーと戦うことでもありませんでした。events_processed.stripe_event_idへの一意インデックスと、ハンドラ先頭のガードです。パッチ全体で11行でした。

ハンドラのタイミングも重要

プロバイダーには厳格なタイムアウトがあります。GitHub: 10秒。Discordのインタラクションエンドポイント: 3秒。StripeとShopifyはより寛容(約30秒)ですが、遅いとそれでもリトライされます。パターンは同じ: 速く応答し、作業は非同期で

async function handleWebhook(event) {
  if (await alreadyProcessed(event.id)) {
    return { status: 200 };
  }

  // Don't await the heavy work — queue it.
  await queue.enqueue('process-stripe-event', event);

  // 200 immediately. Worker picks up the event.
  return { status: 200 };
}

キューワーカーが重い処理を担います。Webhookハンドラは基本的に重複排除器+キュープロデューサーです。このパターンはリトライ下でも正しく保たれます。キュープロデューサー自体が冪等(イベントIDでのinsert-if-new)で、二重エンキューはワーカー内の同じガードが捕まえるからです。

どうかローカルでテストを

冪等性のテストこそ、トンネルのリプレイが作られた理由です。トンネルのリクエスト履歴でイベント配信を1つ捕捉し、ローカルハンドラに対して10回リプレイします。最初の呼び出しはデータベースに書き込むはず。次の9回は触れずに200を返すはず。DBログを見て確認しましょう。

これはほとんどのチームが飛ばすテストで、ほとんどのチームが飛ばすべきでないテストです。

プロバイダー別のリトライポリシー

  • Stripe: 3日間リトライ、指数バックオフ。16回試行後にスキップ。
  • GitHub: 8回試行、約8時間で完了。その後は配信を失敗としてマーク。
  • Shopify: 48時間、指数バックオフ。48時間で19回連続失敗するとWebhook購読を無効化。
  • Twilio: デフォルトで1回リトライ、最大3回まで設定可能。
  • Slack: バックオフ付きで3回リトライ(1秒、30秒、5分)。

重複排除のTTLは、気にする最長期間プラス余裕に設定しましょう。

結論

重複配信は契約であって欠陥ではありません。incrementsend_emailcharge_cardを重複排除キーなしで呼ぶWebhookハンドラを出荷したなら、バグを出荷したのです。パッチは小さい。顧客のクレーム後に気づくのは高くつきます。

これをエンドツーエンドでテストする方法はリプレイデバッグガイドが扱います。署名検証はこの手前にあります — 署名ガイドが扱います。まさにこのワークフローを中心に作られたトンネル+リプレイのためにPortPreviewのウェイトリストに登録してください。

よくある質問

なぜWebhookプロバイダーは同じイベントを2回送るのですか?
プロバイダーはat-least-once deliveryを使います。時間内に2xxレスポンスを受け取れないとリトライします。元のハンドラが実際には成功していて応答が失われた場合でもです。重複配信を例外ケースではなく、システムの保証された仕様として扱いましょう。
Webhookハンドラを冪等にするには?
プロバイダーが送るイベントID(Stripeのevt_xxx、GitHubのX-GitHub-Deliveryヘッダー、ShopifyのX-Shopify-Webhook-Id)を重複排除キーとして使います。ハンドラ先頭で一意制約付きで挿入し、すでに存在してinsertが失敗したら、ビジネスロジックを再実行せずに200を返します。
重複排除キーはどのくらい保持すべきですか?
少なくともプロバイダーの最長リトライ期間と同じだけ。Stripeは3日、Shopifyは48時間リトライします。7日のTTLで主要プロバイダーを余裕を持ってカバーできます。保存場所があるなら、永久に保持しても構いません — 監査にも役立ちます。