Rebounder Tech Blog

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

supabase-js の条件付き UPDATE は error を見ないと処理済み判定を誤る

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

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

結論

supabase-js の条件付き UPDATE は、DB 側の書込エラーでも例外を投げず data:null と error を返すため、error を確認せず data の有無だけで判定すると、一時的な書込失敗を「対象0件=既に処理済み」と区別できずに取りこぼす。

結論

supabase-js の条件付き UPDATE は、書込エラーでも例外を投げません。 data:nullerror を返すだけです。冪等化のために「対象0件=既に処理済み」という判定を data の有無だけで行っていると、一時的な書込失敗まで「処理済み」に含めてしまいます。

Stripe の webhook 処理では、これが「本物のカード入金を検知したのに、領収書が永久に発行されない」という形で表面化しました。直し方は、error を見て投げる、それだけです。

症状

  • カード決済の webhook は届いている(Stripe 側のログに送信記録がある)
  • 契約の入金ステータスが更新されていない
  • 領収書メールが送られていない
  • Stripe 管理画面を見ても、配信は成功扱いになっている(再配信されない)

エラーは記録されていません。処理は成功したことになっているのに、結果だけが起きていないという食い違いでした。

原因

入金確定の処理は、条件付き UPDATE で冪等に実装されています。

const { data: claimed, error } = await admin
  .from("contracts")
  .update(patch)
  .eq("id", contractId)
  .in("payment_status", ["未請求", "請求済"])
  .select("id")
  .maybeSingle();

if (!claimed) return { claimed: false }; // ← error を見ずに 0件=既処理と判定していた

payment_status が対象の状態でなければ、UPDATE は0件を更新して終わります。これは同じイベントが2度届いたときの正しい挙動で、2回目は claimednull になり、何もせず抜けます。意図した冪等 no-op です。

問題は、error が入っているときも datanull になることです。supabase-js は DB 側のエラーで例外を投げず、{data:null, error} という同じ形の値を返します。呼び出し側が data の有無だけで「既に処理済みかどうか」を判定していると、

対象0件(意図した冪等 no-op)        → data:null, error:null
一時的な書込失敗(pooler timeout 等) → data:null, error:{...}

この2つが、data だけを見ている限り同じ形に見えます。 後者を「既に処理済み」と誤認すると、本物のカード入金を握りつぶしたまま処理が正常終了します。handler が例外を投げないため、呼び出し元の webhook ルートも成功として扱い、200 を返します。Stripe は成功した配信を再送しないため、この入金は記録上どこにも残らないまま確定します。

直す

error を明示的に見て、あれば投げます。

if (error) {
  throw new Error(`markContractPaid update failed: ${error.message}`);
}
if (!claimed) return { claimed: false }; // 既に入金済=冪等 no-op

error のチェックを data の判定より先に置くのが要点です。これで「書込失敗」と「意図した冪等 no-op」が明確に分かれます。

投げた例外は、呼び出し元の webhook ルートで拾われます。

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);
  return new Response(`error: ${e instanceof Error ? e.message : "error"}`, { status: 500 });
}

冪等台帳(stripe_events)への INSERTevent.id の一意制約で守られているため、Stripe が再配信しても二重処理にはなりません。throw して 500 を返すことは「失敗した」という信号であると同時に、Stripe の再配信という回復手段を起動するスイッチでもあります。 ここを黙って 200 にしてしまうと、その回復手段自体が使われません。

一般化できる形

この形は入金確定に限りません。「対象0件」に複数の意味がありうる条件付き操作すべてに当てはまります。

  • 意図した0件(重複・既処理・対象外)
  • 意図しない0件(一時的な書込失敗・接続断・タイムアウト)

supabase-js に限らず、SDK が例外を投げずに {data, error} のようなタプルでエラーを返す設計では、data の中身だけで分岐する前に、必ず error を見る必要があります。この実装では冪等台帳への INSERT 側でも同じ形の分岐をしています。

if (insErr) {
  if (String(insErr.code ?? "").includes("23505")) {
    return Response.json({ received: true, duplicate: true }); // 意図した重複
  }
  return new Response("ledger insert failed", { status: 500 }); // 意図しない失敗
}

エラーコードで「意図した重複」と「意図しない失敗」を明示的に分けているのがポイントです。片方を握りつぶすためのコードが、もう片方まで一緒に握りつぶしてしまわないよう、分岐の条件を具体的に書いています。

よくある質問

Q1なぜ「0件」と「書込失敗」を区別する必要があるのですか?

条件付き UPDATE で対象が0件になる理由は2つあります。1つは意図どおりの冪等 no-op(既に処理済みで対象行がもう条件に合わない)。もう1つは pooler のタイムアウトや直列化衝突・接続断による一時的な書込失敗です。supabase-js はどちらの場合も data は null か空になり得ますが、後者では error にエラー情報が入ります。error を見ずに data の有無だけで判定すると、この2つが同じ「0件」として扱われます。

Q2誤認すると具体的に何が起きますか?

本物のカード入金を「既に入金済」と誤認し、領収書発行と紹介手数料の確定を永久にスキップします。さらに handler が例外を投げないため、webhook のルートは処理成功として 200 を返し、Stripe はイベントを再配信しません。入金自体はカード会社側では成立しているのに、記録側の反映だけが失われた状態が固定されます。

Q3throw するとどう回復するのですか?

呼び出し元の webhook ルートは、処理中に例外が出ると冪等台帳(stripe_events)の該当行を削除してから 500 を返します。Stripe は 500 応答を失敗とみなし、同じイベントを再配信します。台帳への INSERT は event.id の一意制約で守られているため、再配信されても二重処理にはなりません。error を握りつぶさずに throw することで、この再配信による自動回復の経路に載ります。

Q4領収書発行や紹介手数料の失敗は、なぜ握りつぶしてよいのですか?

入金確定(UPDATE)とその後続の副作用(領収書発行・紹介手数料確定)とで、失敗時に必要な挙動が違うためです。UPDATE の失敗は入金の記録自体が欠けるので必ず再試行させますが、後続処理まで例外にすると、入金確定は成功しているのに webhook 全体が 500 になり無駄な再配信を招きます。後続は個別に catch し、入金確定だけを壊さず優先しています。