Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Stripe webhookが500で再送されても領収書は二度と送られない

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

idempotency claimを勝ち取った後のbest-effortな副作用を裸awaitで呼ぶと、そのthrowがwebhookハンドラ全体を500にする一方、Stripeの再配信は本処理を既にclaim済みとしてスキップするため、副作用だけが永久に再送されなくなる。

結論

idempotency claimを勝ち取った後に呼ぶbest-effortな副作用を裸awaitで呼ぶと、そのthrowがwebhookハンドラ全体を500にする一方で、Stripeの再配信は本処理が既にclaim済みであることを理由に処理そのものをスキップするため、副作用だけが永久に再送されなくなる。 入金反映(本処理)は最初の1回で正しく完了しているため、誰も気づかない。

症状

カード決済のWebhookは、markContractPaidでpayment_statusを条件付きUPDATEでclaimしたあと、領収書発行を呼んでいた。

// persist.ts:34-45(修正前・b8ca5e9時点)
  const { data: claimed } = await admin
    .from("contracts")
    .update(patch)
    .eq("id", contractId)
    .in("payment_status", ["未請求", "請求済"])
    .select("id")
    .maybeSingle();

  if (!claimed) return { claimed: false }; // 既に入金済=冪等 no-op(領収書を再送しない)

  // システム発行 = issuerId null。失敗しても入金反映は壊さない(best-effort)。
  await issueReceiptAndEmail(admin, contractId, null);

コメントは「失敗しても入金反映は壊さない」と書いているが、実装は裸のawaitのままだった。issueReceiptAndEmailが例外を投げると、その場でthrowがそのまま関数の外へ伝播する。

呼び出し元のwebhookハンドラは、この例外を外側のtry/catchで受けて500を返す設計になっている。

// route.ts:66-81
  try {
    const { contractId } = await handleStripeEvent(admin, stripe, event);
    await admin
      .from("stripe_events")
      .update({ processed_at: new Date().toISOString(), contract_id: contractId })
      .eq("id", event.id);
    return Response.json({ received: true });
  } catch (e) {
    // 台帳行を削除し 500 → Stripe の再配信で再処理させる(at-least-once)。
    await admin.from("stripe_events").delete().eq("id", event.id);
    const msg = e instanceof Error ? e.message : "error";
    await notifySlack(
      `⚠️ Stripe Webhook 処理で例外(${escapeSlack(event.type)}: ${escapeSlack(msg)})→再配信待ち/手動確認 ${slackLink("/admin/contracts", "契約")}`
    );
    return new Response(`error: ${msg}`, { status: 500 });
  }

ここで冪等台帳stripe_eventsの該当行は削除される。Stripeから見れば「このevent.idはまだ処理されていない」ことになり、at-least-onceの原則どおり再配信が来る。

ところが再配信が届いたとき、contracts.payment_statusは最初の呼び出しで既に「入金済」へ書き換わっている。markContractPaidのUPDATEは.in("payment_status", ["未請求", "請求済"])という条件付きなので、再配信ではこの条件にマッチせずclaimedはnullになり、if (!claimed) return { claimed: false };で早期returnする。領収書発行の行まで二度と到達しない。

台帳行(stripe_events)のレベルでは「claimされていない」ように見えるのに、業務データ(contracts.payment_status)のレベルでは既に「claim済み」。この2つのclaim状態がずれているのが本質で、再送は前者を信じて後者の状態に初めて気づかず終わる。

原因

「失敗しても本処理は壊さない」という意図自体は正しい設計判断だった。入金反映(本処理)と領収書発行(副作用)を同じ失敗の重大度で扱うべきではない。問題は、その意図をコードの構文で保証していなかったことにある。裸のawaitは、意図に反して例外をそのまま外へ伝播させる。

外側のcatchは「webhookハンドラ全体が失敗した」という前提で書かれており、台帳行の削除とStripeへの500応答という、本処理の失敗時の挙動がそのまま発動する。しかし実際に失敗したのは副作用だけで、本処理(payment_statusの更新)は直前のUPDATE文で既にコミットされている。この「本処理は成功、副作用だけ失敗」という状態を、外側のtry/catchは区別できない。

直し方

issueReceiptAndEmailの呼び出しをtry/catchで囲み、失敗してもpaid状態を維持したままログに残すだけにした。

// persist.ts:42-53(修正後・56f097fb時点)
  if (!claimed) return { claimed: false }; // 既に入金済=冪等 no-op(領収書を再送しない)

  // システム発行 = issuerId null。失敗しても入金反映は壊さない(best-effort・Reviewer nit:
  // 裸 await だと throw で webhook が 500→再配信は claim 負け→領収書だけ永久未送、になる)。
  try {
    await issueReceiptAndEmail(admin, contractId, null);
  } catch (e) {
    console.error("[stripe] receipt issue failed (paid state kept):", e);
  }

これで領収書発行が失敗しても、外側のcatchまで例外が伝播せず、webhookは200を返して正常終了する。本処理の成功と副作用の失敗が、構文のレベルで分離された。

副作用の失敗はconsole.errorでログに残るだけで、再送による自動復旧は無くなる。これは設計として正しい。再送に頼るのは「まだclaimされていない」場合だけで、今回のように本処理が既に完了している副作用は、再送ではそもそも直らない非対称な失敗だからだ。

再発防止

このコミットでは同時に、冪等台帳へのINSERT失敗を理由を問わず「二重配信」として200で握り潰していた別の分類漏れも直されている(本処理そのものを喪失させる、より重い問題)。「失敗しても本処理は壊さない」というコメントと、それを保証する構文(try/catch)が一致しているかどうかは、markContractPaidのようなbest-effort処理を見るときの固定の確認点になった。コメントに「best-effort」「失敗しても壊さない」と書かれた箇所で、実装が裸のawaitのままになっていないかは、同種の処理を追加・変更するたびに見るべき箇所として残っている。

よくある質問

Q1webhookが500を返しているのに、なぜStripeの再送で直らないのですか?

500を受けたStripeは再配信するが、再配信が届いた時点でDB側のpayment_statusは既に「入金済」に書き換わっている。本処理のUPDATEは「未請求または請求済のときだけ」という条件付きなので、再配信ではこの条件にマッチせずclaimされず、処理そのものが早期returnする。副作用(領収書発行)を呼ぶ行まで到達しないため、再送のたびに同じ理由で素通りする。

Q2try/catchで囲むだけで直るのはなぜですか?

本処理(payment_statusの更新)と副作用(領収書発行)の成功・失敗を非対称に扱えるようにするため。try/catchで副作用の例外を握ってログに残すだけにすれば、webhookは200を返して正常終了し、本処理の成功がそのまま確定する。副作用の失敗は別途ログやSlack通知で拾う対象になり、webhook全体の成否とは切り離される。

Q3似たような「best-effortのつもりの処理」を見分けるコツはありますか?

コメントや変数名に「失敗しても壊さない」「best-effort」と書きながら、実装がbareなawaitのままになっている箇所を探す。コメントの意図と、例外が伝播する実装が一致していないのがこのパターンの特徴で、意図を保証する構文(try/catch)が伴わないコメントは検索語としても使える。

確認した環境

  • stripe ^22.3.1 / Next.js 16.2.7
  • 2026-07-12 に社内レビュー指摘への対応として修正(コミット 56f097fb)

この記事の根拠

  • TypeScriptファイル 42〜45行目コミット b8ca5e9
  • TypeScriptファイル 42〜53行目コミット 56f097f
  • TypeScriptファイル 66〜81行目コミット 56f097f

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。