Idempotency-Keyとは?APIリトライを安全にする実装とテスト
公開:
決済APIを呼んだらタイムアウトしたので、もう一度送った。ところが1回目も実は成功していて、同じ注文に2回課金されていた——API を作る側にも呼ぶ側にも、起こりうる事故です。クライアントからは、タイムアウトしたリクエストがサーバーに届いて処理されたのかどうかが分かりません。
この問題を仕組みで防ぐのが Idempotency-Key(冪等性キー) です。クライアントが操作ごとに一意なキーをリクエストに付け、サーバーは同じキーの再送に対して「前回の結果」を返します。この記事では、HTTPの冪等性の定義から、IETFの仕様案とStripeの挙動、サーバーとクライアントの実装のポイント、テストの観点までをまとめます。
なぜPOSTのリトライは危険なのか
HTTPの仕様(RFC 9110)は、同じリクエストを何回送っても、サーバーに意図される効果が1回送ったときと同じになるメソッドを 冪等(idempotent) と呼んでいます。RFC 9110が定義するメソッドのうち冪等なのは、PUT・DELETEと、GET・HEAD・OPTIONS・TRACEのような安全なメソッドです。POSTは冪等ではありません(PATCHも冪等ではないメソッドとして定義されています)。
| メソッド | 冪等か | 同じリクエストを2回送ると |
|---|---|---|
| GET / HEAD / OPTIONS | 冪等 | 取得するだけなので、何回でも同じ |
| PUT / DELETE | 冪等 | 「この状態にする」「消す」なので、2回目も結果は同じ |
| POST / PATCH | 冪等ではない | 注文や決済が2件作られることがある |
RFC 9110は、冪等でないメソッドのリクエストを、クライアントが自動で再送すべきではない(SHOULD NOT)としています。例外は、そのリクエストが実際には冪等だと知る手段があるか、元のリクエストが適用されなかったと分かる手段がある場合です。Idempotency-Keyは、この「冪等だと知る手段」をPOSTに与える仕組みだと言えます。
なお、冪等でも応答まで同じになるとは限りません。DELETEの1回目は200、2回目は404を返す、ということはありえます。冪等性が約束するのはサーバー側の効果であって、レスポンスの中身ではない点に注意が必要です。
Idempotency-Keyの仕組み
流れはシンプルです。
- クライアントは、操作(「この注文の決済」)ごとに一意なキーを作り、
Idempotency-Keyヘッダーに入れて送る - サーバーは、初めて見るキーなら通常どおり処理し、キーと結果を保存する
- 同じキーで再送が来たら、もう一度処理せずに、保存しておいた結果を返す
クライアントは、タイムアウトしても「同じキーで再送する」だけで済みます。1回目が処理されていれば保存済みの結果が返り、処理されていなければ初回として処理されます。どちらでも、決済は1回だけです。
IETFの仕様案(draft-ietf-httpapi-idempotency-key-header)
このヘッダーは、IETFのHTTPAPIワーキンググループで標準化に向けた仕様案(The Idempotency-Key HTTP Header Field)として議論されてきました。ただし、2026年10月時点の最新版(-07、2025年10月公開)は2026年4月に有効期限が切れており、RFCにはなっていません。仕様案の内容は次のとおりです。
- 値はStructured Fieldsの文字列で、引用符付きで書く(例:
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324")。UUIDのようなランダムな値を推奨 - キーは一意でなければならず、本文の違う別のリクエストに使い回してはならない
- キーの有効期限の方針は、サーバーが決めてドキュメントに書く
- 本文からフィンガープリント(チェックサムなど)を作り、キーと組み合わせて判定してもよい
エラーの返し方も例示されています。
| 状況 | ステータス |
|---|---|
| キーが必要な操作なのに、キーがない | 400 Bad Request |
| 同じキーを、本文の違うリクエストに使い回した | 422 Unprocessable Content |
| 同じキーの1回目が、まだ処理中 | 409 Conflict |
仕様案の例では、どのエラーも本文をProblem Details(application/problem+json)で返しています(Link ヘッダーでドキュメントを示す例もあります)。Problem Detailsの形式は RFC 9457対応のAPIエラーレスポンス設計 で、400・409・422の使い分けの考え方は Problem Details実装パターン|422・401/403・429の返し方 で解説しています。
Stripeの場合
仕様案より前から、決済APIでは同じ名前のヘッダーが使われてきました。代表的なStripe(API v1)の公式ドキュメントには、次のように書かれています。
- キーは最大255文字。V4 UUIDなど十分にランダムな値を推奨し、メールアドレスのような個人情報はキーに使わない
- エンドポイントの実行が始まった後の最初の結果(ステータスとボディ)を保存し、同じキーの再送にはそれを返す。500エラーも保存して返す
- パラメーターの検証エラーや、同じキーで実行中のリクエストとの競合(409)は、実行が始まっていないので保存しない。直して再送できる
- 同じキーで違うパラメーターを送るとエラーになる
- キーは24時間以上たつと削除されることがあり、その後に同じキーを使うと新しいリクエストとして扱われる
- 再送に対して保存済みの結果を返したときは、
Idempotent-Replayed: trueヘッダーが付く
Idempotent-Replayed はStripe独自のヘッダーで、仕様案にはありません。また、Stripeの例ではキーを引用符なしで送ります。仕様案に沿うサービスと、Stripe型のサービスでは、ヘッダーの値の書き方が違う点に気をつけてください。
サーバー側の実装ポイント
実装で決めることは、主に4つです。
1. 何をキーにして保存するか。 クライアントが送ったキーをそのまま使うと、別の利用者が同じ値を送ったときに他人の結果を返してしまいます。仕様案もセキュリティの節で、クライアント固有の情報と組み合わせた複合キーを推奨しています。「ユーザーID + エンドポイント + キー」で引くのが基本です。
2. 本文が同じかどうかの判定。 キーと一緒に本文のハッシュ(フィンガープリント)を保存し、同じキーで違う本文が来たら422を返します。これがないと、クライアントの不具合でキーを使い回したときに、違う内容の決済に対して前回の結果を返してしまいます。
3. 同時に来た再送の扱い。 1回目の処理中に再送が来ることがあります。キーの保存と処理の開始をアトミックに行い、2つ目のリクエストには409を返します。DBなら一意制約、Redisなら SET の NX(キーがないときだけ書き込む)が使えます。
4. 保存期間と、保存しない結果。 何時間保存するかを決めてドキュメントに書きます(Stripeは24時間)。また、処理が始まる前に失敗したもの(入力値の検証エラーなど)は保存せず、クライアントが直して同じキーで送れるようにしておくと親切です。
Redisを使った処理の流れは、たとえば次のようになります(TypeScriptと ioredis の例。エラー処理は省略)。
import { createHash } from 'node:crypto';
const TTL_SECONDS = 24 * 60 * 60;
async function handleWithIdempotency(userId: string, key: string, body: unknown) {
const storeKey = `idem:${userId}:POST:/payments:${key}`;
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex');
// キーがなければ「処理中」として書き込む。書き込めたリクエストだけが処理を進める
const acquired = await redis.set(
storeKey,
JSON.stringify({ state: 'processing', fingerprint }),
'EX', TTL_SECONDS, 'NX',
);
if (!acquired) {
const saved = JSON.parse((await redis.get(storeKey)) ?? '{}');
if (saved.fingerprint !== fingerprint) return problem(422, 'Idempotency-Key is already used');
if (saved.state === 'processing') return problem(409, 'A request is outstanding for this Idempotency-Key');
return { ...saved.response, headers: { ...saved.response.headers, 'Idempotent-Replayed': 'true' } };
}
const response = await createPayment(userId, body); // 実際の決済処理
await redis.set(
storeKey,
JSON.stringify({ state: 'completed', fingerprint, response }),
'EX', TTL_SECONDS,
);
return response;
}
本番で使うには、createPayment が途中で失敗したときの扱い(「処理中」のまま残ったキーをどうするか)、JSONのキーの順序でハッシュが変わらないようにする正規化、Redisが落ちたときの方針なども決める必要があります。決済のように取り消しにくい処理では、Redisではなく決済と同じDBのトランザクションの中でキーを保存する設計もよく使われます。
クライアント側のポイント
- キーは操作ごとに1つ作り、再送では使い回す。 再送のたびに新しいキーを作ると、サーバーからは別の操作に見え、二重決済を防げません。ブラウザやNode.jsなら
crypto.randomUUID()で作れます - 新しい操作には新しいキーを使う。 「金額を直してもう一度」は別の操作です。同じキーで送ると422になります
- 再送するのはタイムアウト・ネットワークエラー・5xxのときだけ。 400や422はリクエスト自体を直す必要があるので、そのまま再送しても結果は変わりません。409は、少し待って同じキーで再送します
- 再送の回数と間隔に上限を設ける。 間隔を少しずつ延ばし、上限に達したら利用者に知らせます
const key = crypto.randomUUID(); // 再送でも同じキーを使う
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch('/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify({ amount: 9800, currency: 'JPY' }),
signal: AbortSignal.timeout(3000),
});
if (res.status !== 409 && res.status < 500) return res; // 成功、または直す必要のあるエラー
} catch {
// タイムアウトやネットワークエラーは、同じキーで再送する
}
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
}
「再送する前に、決済が作られたかどうかを照会する」という方法もあります。ただ、タイムアウトした1回目の決済IDはクライアントに届いていないので、何を照会すればよいか分からないことが多いのです。Idempotency-Keyなら、同じキーで再送するだけで1回目の結果が手に入ります。
テストの観点
Idempotency-Keyの不具合は、正常系のテストでは見つかりません。最低限、次のケースを確かめておきます。
| # | ケース | 期待する結果 |
|---|---|---|
| 1 | 同じキー・同じ本文で2回送る | 2回とも同じ結果。決済は1件だけ |
| 2 | 同じキーで本文を変えて送る | 422。決済は増えない |
| 3 | 1回目の処理中に同じキーで送る | 409。クライアントは待って再送する |
| 4 | キーなしで送る | 400(キーを必須にしている場合) |
| 5 | 1回目がタイムアウトした後、同じキーで再送する | 1回目の結果が返る。決済は1件だけ |
| 6 | 保存期間が過ぎた後に同じキーで送る | 新しいリクエストとして処理される(仕様として決めたとおりか) |
| 7 | 1回目が500で終わった後に同じキーで送る | 保存した500を返すのか、もう一度処理するのか、仕様どおりか |
クライアント側では、ケース3と5の扱い(409で待って再送するか、タイムアウト後に同じキーを使うか)を確かめることになります。エラーレスポンスのテスト全般の考え方は APIエラーレスポンスのテスト設計|観点と再現方法 にまとめています。
開発チームより
ScenarioMockのProプランの購入には、Stripeを使っています。2026年9月に課金処理を見直した際、二重課金につながりうる経路を塞ぐ修正を入れました。そのうちの1つが、Idempotency-Keyです。
ScenarioMockでは、購入画面(Stripe Checkout)を開く前に、利用者ごとにStripeの「顧客(Customer)」を1つだけ作り、そのIDを保存しています。顧客が利用者ごとに1つに決まっていれば、すでに有効なサブスクリプションがあるかを調べて、二重の購入を止められるからです。ところが、購入ボタンが短い間に2回押されるなどして作成のリクエストが同時に2つ届くと、顧客が2つ作られてしまうおそれがありました。
そこで、顧客を作るStripeのAPIに、利用者IDから決まるIdempotency-Key(customer-create-<利用者ID>)を付けました。同時に2回呼ばれても、Stripeは2回目に1回目と同じ顧客を返します。あわせて、有効なサブスクリプションがあれば409を返して購入画面を開かないこと、開いたままの古い購入画面は無効にしてから新しく作ることも入れました。
ここで使ったのは「ランダムなUUID」ではなく、「利用者ごとに1つ」という業務上の意味から決まるキーです。Stripeのドキュメントでも、ショッピングカートのIDのように、利用者に関係するオブジェクトからキーを作る方法が紹介されています。Idempotency-Keyは決済APIを「呼ぶ側」でも使える道具なので、外部のAPIに作成系のリクエストを送るコードでは、一度その必要がないか確かめてみることをおすすめします。
ScenarioMockで再送のテストを再現する
上のテストのケースを、本物の決済サービスを使わずに試せるデモを公開しています。ブラウザで 共有ページ を開くとエンドポイントの一覧からリクエストを送れます。curlでも試せます。
H=https://demo-mock--payments-idempotency.scenariomock.com
K=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid) # 操作ごとに新しいキー
# 同じキーで2回: どちらも 201。2回目には Idempotent-Replayed: true が付く
curl -i -X POST $H/payments -H 'Content-Type: application/json' -H "Idempotency-Key: $K" -d '{"amount":9800,"currency":"JPY"}'
curl -i -X POST $H/payments -H 'Content-Type: application/json' -H "Idempotency-Key: $K" -d '{"amount":9800,"currency":"JPY"}'
# キーなし → 400、本文違いの再現 → 422、処理中の再現 → 409
curl -i -X POST $H/payments -H 'Content-Type: application/json' -d '{"amount":9800,"currency":"JPY"}'
curl -i -X POST $H/payments -H 'Content-Type: application/json' -H 'Idempotency-Key: demo-conflict' -d '{"amount":1200,"currency":"JPY"}'
curl -i -X POST $H/payments -H 'Content-Type: application/json' -H 'Idempotency-Key: demo-in-progress' -d '{"amount":9800,"currency":"JPY"}'
# 1回目は10秒かかる → 3秒でタイムアウトさせ、同じキーで再送するとすぐ 201
K2=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)
curl -m 3 -X POST $H/transfers -H 'Content-Type: application/json' -H "Idempotency-Key: $K2" -d '{"amount":50000,"currency":"JPY","toAccount":"0000000"}'
curl -i -X POST $H/transfers -H 'Content-Type: application/json' -H "Idempotency-Key: $K2" -d '{"amount":50000,"currency":"JPY","toAccount":"0000000"}'
決済の照会(GET /payments/pay_demo_0001 は200、それ以外のIDは404)もあります。
このデモは、ScenarioMockの次の機能で作っています。
- シーケンス(N回目に返すレスポンスを切り替えるシナリオ)を、
Idempotency-Keyヘッダーの 値ごとに 数える設定(per_header:idempotency-key)。1回目は201、2回目以降は同じ201にIdempotent-Replayed: trueを付けて返す。回数は、そのキーの最初のリクエストから24時間で0に戻る - 条件分岐のシナリオで、ヘッダーがない(
missing)ときは400、値がdemo-conflictなら422、demo-in-progressなら409を返す。シーケンスより先に評価させる /transfersの1回目のレスポンスには、10秒の遅延(delay_ms)を設定
ただし、このデモが返すのは あらかじめ設定した固定のレスポンス です。1回目の結果を保存して返しているわけではないので、送った金額が違っても、キーが違っても、同じ決済IDと金額が返ります。また、「本文が違う」「処理中」は前のリクエストを見て判定しているのではなく、決まったキーの値で再現しています。サーバーの実装を確かめる道具ではなく、クライアントが400・409・422や再送の結果を正しく扱えるかを確かめるためのモック として使ってください。モックAPIそのものの考え方は モックAPIとは?必要な理由から作り方、無料ツールの選び方まで で解説しています。
まとめ
- POSTは冪等ではない。タイムアウト後に安全に再送するには、Idempotency-Keyのような「冪等だと知る手段」が要る
- クライアントは操作ごとに一意なキーを作り、再送では同じキーを使う。サーバーは同じキーに保存した結果を返す
- IETFの仕様案は、キーなし400・本文違い422・処理中409を例示している(2026年10月時点でRFCにはなっていない)。Stripeは24時間保存し、
Idempotent-Replayedヘッダーで再送への応答だと示す - サーバーは「利用者 + キー」で保存し、本文のフィンガープリントを比べ、保存と処理開始をアトミックにする
- テストでは、同じキーの再送、本文違い、処理中、タイムアウト後の再送、保存期間切れを確かめる
まずは自分のAPIの中で「2回実行されると困るPOST」を洗い出し、キーを受け付けているか、クライアントが再送で同じキーを使っているかを確かめるところから始めてみてください。
同じキーの再送やタイムアウト後の再送を、本物の決済サービスを使わずにテストしてみませんか。ScenarioMockなら、ヘッダーの値ごとに応答を切り替えるモックAPIを数分で作れます。
冪等なAPIのモックを無料で作る