ある朝、ユーザーから「同じ商品が二回引き落とされている」というメールが届く。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)を付けると、運用時に問題が起きたときに原因を切り分けやすくなります。
重複排除ストアの実装パターン4つ
冪等性キーが設計できたら、次は「このキーは処理済みか?」を Atomic に判定するストアを選びます。選択肢ごとに性能と一貫性のトレードオフがあるので、自分のワークロードに合うものを選んでください。
パターン1: Cloudflare KV — エッジ最適・最弱整合性
Cloudflare Workers で Hono を使ってAPIを書いているなら、KV は最もデプロイしやすい選択肢です。ただし KV は**結果整合(Eventually Consistent)**で、書き込みから世界中のエッジに反映されるまで最大60秒のラグがあります。これは「同じリージョンからの直後のリクエスト」では問題になりませんが、「グローバルに分散したクライアントからの並行リクエスト」では不十分です。Webhook の受信実装そのものについては AntigravityでWebhook受信エンドポイントを取りこぼしなく作る実践パターン も併読してください。
// src/lib/idempotency/kv-store.ts
import type { KVNamespace } from "@cloudflare/workers-types" ;
export class KvDedupeStore {
constructor ( private readonly kv : KVNamespace , private readonly ttlSec = 86400 ) {}
/**
* キーを「予約」できれば true(=新規処理)、既存なら false(=重複)。
* KV は Compare-And-Swap がないので、put + get で競合を緩和する。
* 厳密な exactly-once が必要なら Postgres / Durable Objects を使う。
*/
async tryAcquire ( key : string , payload : { startedAt : number }) : Promise < boolean > {
const existing = await this .kv. get (key);
if (existing !== null ) return false ;
await this .kv. put (key, JSON . stringify (payload), { expirationTtl: this .ttlSec });
return true ;
}
async markCompleted ( key : string , result : unknown ) : Promise < void > {
const value = JSON . stringify ({ status: "completed" , result, completedAt: Date. now () });
await this .kv. put (key, value, { expirationTtl: this .ttlSec });
}
async getRecord < T >( key : string ) : Promise < T | null > {
const v = await this .kv. get (key, "json" );
return (v as T ) ?? null ;
}
}
KV の致命的な弱点は、tryAcquire の get と put の間に競合が入ると両方が「新規」と判断してしまうことです。並行性が低いユースケース(個人 SaaS の Webhook など)では実用上問題になりませんが、QPS が3桁を超える本番では推奨できません。
パターン2: Cloudflare Durable Objects — 強整合・1キー1インスタンス
KV の弱点を解消したいなら、Durable Objects がエッジで使える最も強力な選択肢です。1キーにつき1つの DO インスタンスがアトミックに動くため、競合が原理的に発生しません。
// src/lib/idempotency/durable-object.ts
export class DedupeDO {
private state : DurableObjectState ;
constructor ( state : DurableObjectState ) {
this .state = state;
}
async fetch ( request : Request ) : Promise < Response > {
const url = new URL (request.url);
const key = url.searchParams. get ( "k" );
if ( ! key) return new Response ( "missing key" , { status: 400 });
if (request.method === "POST" && url.pathname === "/acquire" ) {
// ストレージは DO 内で常に強整合
const existing = await this .state.storage. get (key);
if (existing) return Response. json ({ acquired: false });
await this .state.storage. put (key, { status: "in_progress" , at: Date. now () });
return Response. json ({ acquired: true });
}
if (request.method === "POST" && url.pathname === "/complete" ) {
const body = ( await request. json ()) as { result : unknown };
await this .state.storage. put (key, {
status: "completed" ,
result: body.result,
at: Date. now (),
});
return Response. json ({ ok: true });
}
return new Response ( "not found" , { status: 404 });
}
}
// 呼び出し側
async function withDedupe ( env : Env , key : string , fn : () => Promise < unknown >) {
const id = env. DEDUPE_DO . idFromName (key); // 同じキー → 同じインスタンス
const stub = env. DEDUPE_DO . get (id);
const acq = await stub. fetch ( `https://internal/acquire?k=${ encodeURIComponent ( key ) }` , {
method: "POST" ,
});
const { acquired } = ( await acq. json ()) as { acquired : boolean };
if ( ! acquired) return { skipped: true };
const result = await fn ();
await stub. fetch ( `https://internal/complete?k=${ encodeURIComponent ( key ) }` , {
method: "POST" ,
body: JSON . stringify ({ result }),
});
return { skipped: false , result };
}
Durable Objects は強整合のため、Webhook の重複処理を確実に防げます。ただし1キーあたり1リージョンに固定されるため、グローバルなレイテンシ最適化はしにくく、コストも KV より高めです。「決済・課金など壊れたら困る処理だけ DO、それ以外は KV」のように使い分けるのが現実的でしょう。
パターン3: Upstash Redis — SET NX EX で原子的に予約
サーバーレス向けの Redis である Upstash は、Edge Runtime でも HTTP 経由で使えるためエッジワーカーから直接叩けます。SET key value NX EX 86400 という単一コマンドで「未存在なら作成(86400秒で消える)」が原子的に成立するので、競合の心配がありません。
// src/lib/idempotency/redis-store.ts
import { Redis } from "@upstash/redis" ;
export class RedisDedupeStore {
constructor ( private readonly redis : Redis , private readonly ttlSec = 86400 ) {}
/**
* SET NX EX による Atomic な予約。
* 戻り値:
* "acquired" — 新規予約成功(→ 本処理を実行する)
* "in_progress" — 別ワーカーが処理中(→ ポーリングか即 200 で返す)
* "completed" — 既に完了(→ 保存済み結果を返す)
*/
async tryAcquire ( key : string ) : Promise < "acquired" | "in_progress" | "completed" > {
const set = await this .redis. set (
key,
JSON . stringify ({ status: "in_progress" , at: Date. now () }),
{ nx: true , ex: this .ttlSec },
);
if (set === "OK" ) return "acquired" ;
const existing = await this .redis. get <{ status : string }>(key);
if (existing?.status === "completed" ) return "completed" ;
return "in_progress" ;
}
async markCompleted ( key : string , result : unknown ) : Promise < void > {
await this .redis. set (
key,
JSON . stringify ({ status: "completed" , result, at: Date. now () }),
{ ex: this .ttlSec },
);
}
async getResult < T >( key : string ) : Promise < T | null > {
const v = await this .redis. get <{ status : string ; result ?: T }>(key);
return v?.status === "completed" ? (v.result ?? null ) : null ;
}
}
私はこのパターンを最もよく使います。理由は3つで、第一にレイテンシが KV より速い(HTTP 越しでも 5〜30ms)、第二に強整合、第三に Pub/Sub や Sorted Set など周辺機能を後から拡張しやすい、です。Edge Runtime と Node Runtime の両方で動くので、Cloudflare から Vercel に乗り換えるときもコードがそのまま使えます。
パターン4: PostgreSQL — INSERT ... ON CONFLICT DO NOTHING
最も堅実な選択肢が PostgreSQL です。トランザクション内で冪等性チェックと業務ロジックを一括して行えるので、「重複排除ストアで予約はできたが、その後のビジネス処理で例外が出た」といった中途半端な状態を排除できます。
-- migrations/idempotency.sql
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY ,
status TEXT NOT NULL CHECK ( status IN ( 'in_progress' , 'completed' , 'failed' )),
request_hash TEXT NOT NULL ,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now (),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now (),
expires_at TIMESTAMPTZ NOT NULL DEFAULT ( now () + INTERVAL '24 hours' )
);
CREATE INDEX idx_idem_expires ON idempotency_records(expires_at);
// src/lib/idempotency/postgres-store.ts
import type { Pool } from "pg" ;
export class PgDedupeStore {
constructor ( private readonly pool : Pool ) {}
async tryAcquire ( key : string , requestHash : string ) {
// INSERT が成功した行は「自分が予約した」のみ。他は ON CONFLICT で何もしない。
const inserted = await this .pool. query (
`INSERT INTO idempotency_records (key, status, request_hash)
VALUES ($1, 'in_progress', $2)
ON CONFLICT (key) DO NOTHING
RETURNING key` ,
[key, requestHash],
);
if (inserted.rowCount === 1 ) return { acquired: true } as const ;
// 重複時は既存レコードを返す
const { rows } = await this .pool. query (
`SELECT status, response_body, request_hash FROM idempotency_records WHERE key = $1` ,
[key],
);
const r = rows[ 0 ];
if (r.request_hash !== requestHash) {
// 同じキーで違うリクエスト = クライアントの設計ミス
throw new Error ( "Idempotency-Key reused with different request body" );
}
return {
acquired: false ,
status: r.status as "in_progress" | "completed" | "failed" ,
response: r.response_body,
} as const ;
}
async markCompleted ( key : string , response : unknown ) {
await this .pool. query (
`UPDATE idempotency_records
SET status = 'completed', response_body = $2, updated_at = now()
WHERE key = $1` ,
[key, response],
);
}
}
request_hash を保存して再リクエストと比較する設計は、Stripe API も採用している重要なパターンです。「同じキーなのにペイロードが違う」状況は、ほぼ間違いなくクライアントのバグなので、ここで明示的にエラーを返してあげると後続の調査時間が短縮されます。
どれを選ぶか — ワークロード別の判断軸
実装パターンを並べただけだと選びきれないので、私が現場で使っている判断軸を共有します。
個人開発の SaaS で QPS < 10 :Cloudflare KV で十分。コストもほぼゼロ
Stripe Webhook など決済系 :Postgres か Durable Objects。トランザクション内で業務ロジックと一緒にコミットできる強整合が必要
ジョブキューと連動するワークフロー :Upstash Redis。SET NX の原子性 + 高速性 + Edge / Node 両対応
マルチテナント SaaS で QPS が3桁以上 :Postgres + パーティショニング、または Redis Cluster。KV は CTR 増加時にスケールが頭打ちになります
Antigravity でテスト駆動で組み立てる手順
ここからは Antigravity の AI エージェントを使って、上記の Postgres パターンをテスト駆動で組み立てる手順を示します。「AI が書いたコードが動くか不安」という人ほど、このフローはおすすめです。テストが先に書けていれば、AI の出力が正しいかどうかを毎回プログラムが判定してくれます。
Step 1: 失敗するテストを Antigravity に書かせる
最初にやることは、/agent 画面で次のようなプロンプトを投げて、Vitest のテストを生成することです。
src/lib/idempotency/postgres-store.test.ts を作成してください。
PgDedupeStore クラスの tryAcquire / markCompleted を pg-mem (in-memory Postgres) で
テストします。以下のケースを網羅:
1. 初回呼び出しは acquired: true
2. 同一キー + 同一 request_hash の二回目は acquired: false, status: "in_progress"
3. 完了後の三回目は status: "completed", response_body が一致
4. 同一キー + 違う request_hash は throws "Idempotency-Key reused..."
5. 並列で同じキーを 10 回叩いたとき、acquired: true は厳密に1回のみ
各テストには // arrange, // act, // assert コメントを付けてください。
Antigravity のエージェントは pg-mem のセットアップから書いてくれることが多いですが、依存パッケージのバージョンが古いと失敗するので、@types/pg の最新を package.json に追加するように指示するのを忘れないでください。
Step 2: 実装を書かせる前にテストだけ走らせて Red を確認
$ npm test -- src/lib/idempotency/postgres-store.test.ts
FAIL src/lib/idempotency/postgres-store.test.ts
PgDedupeStore
× tryAcquire returns acquired on first call
× tryAcquire returns in_progress on duplicate
...
Tests: 5 failed, 5 total
Red であることを確認してから次に進みます。テストが最初から通ってしまったら、それはモックがおかしいか、対象のメソッドが既に実装されているかのどちらかなので、必ず差し戻します。
Step 3: 実装を Antigravity に書かせて Green にする
src/lib/idempotency/postgres-store.ts を作成してください。
@/src/lib/idempotency/postgres-store.test.ts のテストを全てパスする
最小限の実装を書いてください。トランザクションは使わず、
INSERT ... ON CONFLICT DO NOTHING で実装。
コメントで「なぜそうするか」を1文ずつ添える。
Antigravity は最近のリリースで「テストを参照しながら実装する」モードが安定してきていて、私の体感では3〜5往復で全テストが通るようになります。途中で markCompleted が UPDATE ではなく INSERT を使ってしまうことがあるので、UPDATE で in_progress → completed に遷移すること を明示してあげると一発で通ります。
Step 4: 並列リクエストの統合テスト
ユニットテストでは Promise.all で並列を再現してもよいのですが、本気で本番を信じたいなら Postgres コンテナを立てた統合テストを別ディレクトリに作ります。
// tests/integration/concurrent-acquire.test.ts
import { describe, expect, it } from "vitest" ;
import { Pool } from "pg" ;
import { PgDedupeStore } from "../../src/lib/idempotency/postgres-store" ;
describe ( "PgDedupeStore (integration)" , () => {
it ( "並列100リクエストで acquired は厳密に1回" , async () => {
const pool = new Pool ({ connectionString: process.env. DATABASE_URL });
const store = new PgDedupeStore (pool);
const key = `test:${ Date . now () }` ;
const hash = "abc" ;
const results = await Promise . all (
Array. from ({ length: 100 }, () => store. tryAcquire (key, hash)),
);
const acquired = results. filter (( r ) => r.acquired). length ;
expect (acquired). toBe ( 1 );
await pool. end ();
});
});
このテストが通ると、「並行アクセスでも壊れない」という心理的安全性が手に入ります。本番で Webhook が雪崩を起こしたときに、二重課金の悪夢を見る確率がぐっと下がります。
Temporal / Inngest との統合パターン
ここまではアプリ層の冪等性ですが、ワークフローエンジン層との連携も見ておきます。
Temporal アクティビティでの冪等化
Temporal は耐障害性ワークフローエンジンですが、アクティビティ関数は同じ入力で複数回呼ばれる前提で書く必要があります。冪等性キーを引数に明示的に取るのがおすすめです。詳しい設計指針は Antigravity × Temporal.io 本番実装マスターガイド を参考にしてください。
// activities/charge-payment.ts
import { PgDedupeStore } from "../src/lib/idempotency/postgres-store" ;
export async function chargePayment ( input : {
idempotencyKey : string ; // ワークフローID + アクティビティ名 + リトライ回数 で合成
userId : string ;
amount : number ;
}) : Promise <{ chargeId : string }> {
const store = new PgDedupeStore ( getPool ());
const hash = sha256 ( `${ input . userId }|${ input . amount }` );
const acq = await store. tryAcquire (input.idempotencyKey, hash);
if ( ! acq.acquired && acq.status === "completed" ) {
return acq.response as { chargeId : string }; // 完了済み結果を即返す
}
// 実際の Stripe 呼び出し(Stripe 側にも Idempotency-Key を渡す)
const charge = await stripe.charges. create (
{ amount: input.amount, currency: "jpy" , customer: input.userId },
{ idempotencyKey: input.idempotencyKey },
);
await store. markCompleted (input.idempotencyKey, { chargeId: charge.id });
return { chargeId: charge.id };
}
ここでのポイントは、自前のストアと Stripe の Idempotency-Key を同じ値で揃える ことです。同じキーを使えば、Temporal がリプレイしても Stripe がリプレイしても、最終的な状態が一致します。これは Antigravity Self-Healing エージェントの設計実装 のような自動復旧パターンを設計するときの基礎になります。
Inngest のステップ単位冪等性
Inngest は step.run() の名前と引数から自動で冪等性を担保してくれますが、外部 API への副作用は念のためアプリ層でも二重に守るのが安全です。
import { inngest } from "../inngest" ;
export const sendWelcomeEmail = inngest. createFunction (
{ id: "send-welcome-email" , concurrency: { limit: 50 } },
{ event: "user/created" },
async ({ event , step }) => {
const dedupKey = `welcome-email:${ event . data . userId }` ;
await step. run ( "dedupe-and-send" , async () => {
const store = getStore (); // RedisDedupeStore など
const status = await store. tryAcquire (dedupKey);
if (status === "completed" ) return { skipped: true };
await sendgrid. send ({
to: event.data.email,
from: "no-reply@example.com" ,
templateId: "welcome" ,
});
await store. markCompleted (dedupKey, { sentAt: Date. now () });
return { skipped: false };
});
},
);
観測性 — レイヤーが本当に効いているかを可視化する
冪等性レイヤーは「いざというとき効いてくれる」ものですが、いざというとき=Stripe が50回再送してきた瞬間に、計装が雑だったツケが回ってきます。私が運用するときに必ず入れている計装は3つです。
1. 結果ごとのカウンター
tryAcquire の結果は必ず acquired / in_progress / completed のどれかに収束します。これをカウンターで分けて、ソース(ext / cli / biz)でラベル付けしておくと、重複が急増した瞬間に「どの送信元が原因か」が一目で分かります。
import { metrics } from "@opentelemetry/api" ;
const counter = metrics. getMeter ( "dedupe" ). createCounter ( "dedupe_outcome_total" );
export async function trackedTryAcquire (
store : { tryAcquire : ( k : string ) => Promise < "acquired" | "in_progress" | "completed" > },
key : string ,
source : "ext" | "cli" | "biz" ,
) {
const outcome = await store. tryAcquire (key);
counter. add ( 1 , { outcome, source });
return outcome;
}
Grafana のダッシュボードでは「呼び出し総数」と「completed の割合」を2枚並べておきます。後者が突然跳ねたら、ほぼ間違いなく外部からの再送が暴走しているサインで、早ければ数分以内に気付けます。
2. ストア別のレイテンシヒストグラム
Postgres を選んでも、tryAcquire がホットパスに 200 ms 乗せていたら本末転倒です。ヒストグラムで p99 を SLI として扱いましょう。
const latency = metrics. getMeter ( "dedupe" ). createHistogram ( "dedupe_latency_ms" );
export async function timed < T >( label : string , fn : () => Promise < T >) : Promise < T > {
const start = performance. now ();
try {
return await fn ();
} finally {
latency. record (performance. now () - start, { label });
}
}
await timed ( "dedupe.acquire" , () => store. tryAcquire (key));
私が現場で使っている目安は、Postgres で p99 < 25 ms、Redis で p99 < 15 ms、KV で p99 < 60 ms。これを超え続けるなら、まずコネクションプール、次にワーカーリージョンとストアリージョンの間のネットワーク経路を疑ってください。
3. 3層をつなぐ分散トレース
ワークフローが再実行されたときに「Webhook 受信 → 冪等チェック → アクティビティ実行 → Stripe 呼び出し」までが1本のトレースとして繋がっていると、調査が劇的に楽になります。OpenTelemetry を導入済みなら、各 Span に idempotency_key 属性を付けるだけです。
import { trace } from "@opentelemetry/api" ;
const tracer = trace. getTracer ( "payments" );
export async function chargeWithDedupe ( key : string , amount : number ) {
return tracer. startActiveSpan ( "charge.dedupe" , async ( span ) => {
span. setAttribute ( "idempotency_key" , key);
span. setAttribute ( "amount" , amount);
try {
// ... dedupe + charge
} finally {
span. end ();
}
});
}
「決済はどうなりましたか?」という問い合わせが来たとき、キーを貼ればトレースエクスプローラーから一連の処理が10秒以内に出てくる状態を目指します。出てこないなら、バグの修正より先に計装を直すのが、結局のところ最短距離です。
よくある間違い4つと正しい書き方
実装したつもりでも、運用で発火する典型的な落とし穴があります。私が事故った・他人の事故を見たケースから4つだけ厳選します。
1. キーの TTL が短すぎる
「24時間で消す」と決めて運用していたら、Stripe が3日後に再送してきて二重課金が起きた、というケースがあります。再送の最大期間は送信元によって違うので、送信元のドキュメントを必ず確認 してください(Stripe は最大3日、GitHub は最大8時間)。判断に迷ったら7日に設定しておくと、ストレージコストはほぼ変わらず安全マージンが取れます。
2. 業務処理失敗時にレコードを残してしまう
tryAcquire で予約 → 業務処理で例外 → そのままレコードが in_progress で残ると、次のリトライ時に「処理中だから何もしない」と誤判定して永久に処理されません。失敗時は明示的にレコードを failed に更新するか削除する処理を try/finally で必ず書きます。
const acq = await store. tryAcquire (key, hash);
if ( ! acq.acquired) return acq.response;
try {
const result = await doBusinessLogic ();
await store. markCompleted (key, result);
return result;
} catch (err) {
await store. markFailed (key, String (err)); // ← これを忘れない
throw err;
}
3. Webhook の 200 を返すタイミングが遅すぎる
冪等性チェックは速いのに、その後のビジネス処理が10秒かかって Stripe にタイムアウトされ、再送が大量に飛ぶケース。Webhook は受け取ったら即 200 を返してジョブキューに投げる のが鉄則です。Cloudflare Workers なら ctx.waitUntil()、Vercel なら Promise.resolve() で並走させた直後に Response を返します。
4. クライアント値をそのままキーに使う
Idempotency-Key ヘッダーをそのまま Redis のキーにすると、悪意あるクライアントが極端に長い文字列を送ってきてストアを汚染できます。前述の buildIdempotencyKey のように 必ずハッシュ化して固定長にする のが基本ですが、もう一段、ヘッダーの長さ上限を 256 文字 で弾くミドルウェアを入れておくとさらに安全です。
自分のプロダクトに今日から組み込むためのチェックリスト
冪等性は「いつかやる」と先送りされがちな領域ですが、決済が絡むなら 小さく始めて拡張する のがコツです。記事を読み終えた今、最初に取り組むべきは次のひとつです。
Stripe Webhook ハンドラーで event.id をキーに、選んだストア(KV / Redis / Postgres のどれでも)で重複排除を入れる。 これだけで二重課金事故の確率は劇的に下がります。
そこまでできたら、次は「決済以外で副作用がある処理」をリストアップして、各処理に冪等性キーを割り当てていきます。メール送信・ポイント付与・在庫減算・通知配信あたりが典型的な対象です。
冪等性レイヤーの設計をプロダクトの土台に据えると、後から Temporal や Inngest を導入したときも、ジョブのリプレイで業務がおかしくなる心配がなくなります。書きながら詰まったら、ぜひ Antigravity の /agent に「このコードは並列で安全か」と尋ねてみてください。現時点の AI でも、競合のシナリオを言語化して指摘してくれる精度はかなり上がっています。冪等性の設計をさらに深く掘り下げて