ANTIGRAVITY LABEN
記事一覧/アプリ開発
アプリ開発/2026-05-03上級

Antigravity × TypeScript で作る冪等性キーと重複排除ストア本番設計ガイド

Stripe Webhook の二重課金や Temporal ワークフローの再実行で起きる重複処理を、冪等性キーと重複排除ストアで止める本番設計を、Antigravity を伴走者にして TypeScript で組み上げる実践ガイドです。

antigravity435typescript26idempotency5dedupe-storestripe9temporalcloudflare-kvproduction59

プレミアム記事

ある朝、ユーザーから「同じ商品が二回引き落とされている」というメールが届く。Stripe ダッシュボードを開くと、確かに同じ payment_intent.succeeded が二度処理されています。よく調べると、初回の Webhook がタイムアウト寸前で 200 を返したものの、Stripe 側のリトライキューが先に発火していて、自分のサーバーは「二件の有効なイベント」として両方を処理してしまっていました。これは私が個人開発でぶつかった事故の話です。原因は単純で、冪等性キーで処理済みのイベントを弾く仕組みを入れていなかった、ただそれだけでした。

決済・在庫・ポイント付与・ユーザー作成のように「二回実行されると壊れる処理」を、Stripe・Temporal・Inngest のような外部システムと組み合わせて作るとき、避けて通れないのが**冪等性(Idempotency)重複排除ストア(Dedupe Store)**の設計です。ここでは Antigravity の AI エージェントを実装の伴走者として使いながら、TypeScript で冪等性レイヤーを組み立てる本番設計を、コードと判断軸の両面から解説します。Cloudflare Workers + Hono を主軸の例にしますが、Node.js / Bun / Vercel でも同じパターンが適用できます。

冪等性が破れる4つの典型シナリオ

冪等性の必要性は抽象論で語ると伝わりにくいので、現場で実際に発生する壊れ方を4つ挙げます。自分のプロダクトのどこに同じ落とし穴があるかを、コードを書く前にイメージしてみてください。

1. Webhook の自動再送によるイベント重複

Stripe・GitHub・Slack などの Webhook 送信元は、受信側が 2xx を返すまでイベントを再送します。受信側の処理が重い・タイムアウトに掠った・エラーレスポンスを返してしまったケースでは、同じ event.id が複数回到達します。Stripe の場合、最大3日間にわたって指数的に再送されます。

2. ワークフローエンジンのリプレイ

Temporal や Inngest のような Durable Execution エンジンは、ワーカーがクラッシュしたとき履歴をリプレイして処理を再開します。アクティビティ関数は同じ入力で複数回呼び出される可能性があり、外部 API への課金やメール送信のような副作用を持つ処理は、自前で冪等にしておかないと二重実行されます。詳しくは Antigravity と Inngest で組み立てるイベント駆動 AI ワークフロー でも触れています。

3. クライアントの二重送信

スマートフォンで「購入」ボタンを連打したり、ネットワークが不安定な状態で同じリクエストを再送したりすると、サーバー側に同じ操作が複数到達します。Stripe の API は Idempotency-Key ヘッダーで保護できますが、自前の API ではクライアント任意のキーを受け取って同じ仕組みを実装する必要があります。

4. 並行リクエストの競合

「初回登録ボーナス付与」のようなロジックで、同じユーザーから同時に2リクエストが来た場合、両方が「未付与」と判断してボーナスを二重に付与してしまうことがあります。これは厳密には冪等性の問題ではなく**Time-of-Check to Time-of-Use(TOCTOU)**競合ですが、対策のレイヤーは同じく重複排除ストアです。

これら4つに共通する解決策は「操作を一意に識別する『冪等性キー』を作り、外部の重複排除ストアで『このキーは処理済みか?』を Atomic に判定する」というシンプルな構造です。残りの章は、このキーの作り方とストアの選定・実装に焦点を当てて掘り下げていきます。

冪等性キーの設計 — 「キーは何で作るか」を間違えない

冪等性キーで最も多い失敗は、「キーの粒度を間違える」ことです。粗すぎれば本来別の操作を一括弾きしてしまい、細かすぎれば二重実行を素通しにしてしまいます。実装に入る前に、用途別の設計指針を整理しておきます。

キーの3つの源泉

冪等性キーの作り方は、大きく分けて3パターンあります。

  • 送信元から受け取るキー:Stripe の event.id、GitHub の X-GitHub-Delivery、Slack の X-Slack-Request-Timestamp + signature など、外部システムが既にユニークな ID を発行しているケース。最も信頼できるので、これがあるなら必ず使います
  • クライアントが提供するキー:自前 API で「このリクエストは同じ操作の再送です」と宣言してもらう。フロントエンドで UUIDv4 を生成し、ヘッダー Idempotency-Key に乗せる方式。Stripe の API と同じ規約に揃えると学習コストが下がります
  • ビジネスキーから合成するキーorder_${userId}_${productId}_${date} のように、業務の一意性条件から決定論的に作る方式。SHA-256 でハッシュ化すると安全です。クライアントを信用できない場面・既存のリトライキューに ID 概念がない場面で使います

キーの粒度を3次元で考える

具体的なキーを設計するときは「主体 × 対象 × 時間」の3軸で粒度を決めると失敗しません。

  • 主体:ユーザー単位 / テナント単位 / セッション単位 のどれで重複を防ぐのか
  • 対象:注文 / 決済 / メール送信 / メッセージ投稿 のどの操作に紐づけるのか
  • 時間:永続(同じキーは未来永劫1回のみ)/ 期間内(24時間以内に1回のみ)/ 業務日単位(同じ日内で1回のみ)

たとえば「ポイント付与」は 主体=ユーザー × 対象=キャンペーンID × 時間=永続 がよく合いますが、「メール送信」は 主体=ユーザー × 対象=テンプレートID × 時間=24時間以内 のほうが現実的です。最初は厳しめ(永続)に作って、要件が出てきてから緩める方向に変えると事故が起きにくくなります。

TypeScript での実装

ここまでの設計を TypeScript に落とすと、こうなります。

// src/lib/idempotency/key.ts
import { createHash } from "node:crypto";
 
export type KeyScope =
  | { type: "external"; eventId: string }       // Stripe.event.id 等
  | { type: "client"; key: string }              // X-Idempotency-Key ヘッダー
  | { type: "business"; parts: readonly string[] }; // ["order", userId, productId]
 
const NS = "antigravity-app";  // 名前空間 — 別アプリのキーと衝突させない
 
/**
 * 冪等性キーを正規化して返す。
 * external → そのまま使う(Stripe側の一意性を信頼)
 * client   → クライアント値を sha256 で固定長化(任意文字列対策)
 * business → parts を結合してハッシュ化(決定論的)
 */
export function buildIdempotencyKey(scope: KeyScope): string {
  switch (scope.type) {
    case "external":
      return `${NS}:ext:${scope.eventId}`;
    case "client": {
      const h = createHash("sha256").update(scope.key).digest("hex");
      return `${NS}:cli:${h.slice(0, 32)}`;
    }
    case "business": {
      const joined = scope.parts.join("|");
      const h = createHash("sha256").update(joined).digest("hex");
      return `${NS}:biz:${h.slice(0, 32)}`;
    }
  }
}
 
// 使用例
const k1 = buildIdempotencyKey({ type: "external", eventId: "evt_1NXXX..." });
const k2 = buildIdempotencyKey({
  type: "business",
  parts: ["order", "user_123", "product_456", "2026-05-03"],
});

ポイントは3つです。第一に、名前空間(NS)を必ず付けること。複数アプリで同じ Redis を共有する場合、名前空間がないと別アプリの値を上書きする事故が起きます。第二に、ハッシュ化で固定長にすること。クライアント文字列の長さに依存しないので、ストアのキー長制限に引っかかりません。第三に、判別可能なプレフィックスext / cli / biz)を付けると、運用時に問題が起きたときに原因を切り分けやすくなります。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
Stripe Webhook が同じイベントを何度も再送してきても、決済処理が二重に走らない仕組みをコピーして動かせる形で習得できる
Cloudflare KV / Upstash Redis / PostgreSQL のどれを重複排除ストアに選ぶべきかを、ワークロードと一貫性要件から自分で判断できるようになる
Temporal や Inngest のワークフローからアプリ層までを貫く冪等性レイヤーを設計し、自分のプロダクトに今日から組み込めるようになる
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

アプリ開発2026-06-28
ストリーミング表示がローカルでは動くのに本番だと一括表示に戻るとき — 中間プロキシのバッファリングと沈黙する停止を計測する運用メモ
Antigravity 経由の Gemini ストリーミングを SSE で配信すると、ローカルでは1文字ずつ出るのに本番では数秒固まってから一括で出ます。中間プロキシのバッファリング・アイドル切断・再接続による二重生成を、計測してから潰す運用メモです。
アプリ開発2026-05-01
Antigravity で React Server Components の境界を整理する — Next.js 16 で迷わなくなるワークフロー
Server / Client の境界判断は Next.js 16 で最も詰まりやすいポイントです。Antigravity を境界設計のレビュアーとして使い、`use client` の貼り間違いとシリアライズエラーをゼロに近づける個人開発の実践ワークフローを共有します。
アプリ開発2026-05-01
Antigravity で実装するゼロダウンタイム DB マイグレーション:Expand-Contract パターンと AI 駆動の安全策
本番DBの型変更・カラム名変更・テーブル分割を、ユーザー影響ゼロで遂行するための Expand-Contract パターンを Antigravity の AI 支援とともに完全実装する本番運用ガイドです。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →