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の仕組み

流れはシンプルです。

  1. クライアントは、操作(「この注文の決済」)ごとに一意なキーを作り、Idempotency-Key ヘッダーに入れて送る
  2. サーバーは、初めて見るキーなら通常どおり処理し、キーと結果を保存する
  3. 同じキーで再送が来たら、もう一度処理せずに、保存しておいた結果を返す

クライアントは、タイムアウトしても「同じキーで再送する」だけで済みます。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にはなっていません。仕様案の内容は次のとおりです。

エラーの返し方も例示されています。

状況 ステータス
キーが必要な操作なのに、キーがない 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)の公式ドキュメントには、次のように書かれています。

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のトランザクションの中でキーを保存する設計もよく使われます。

クライアント側のポイント

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の次の機能で作っています。

ただし、このデモが返すのは あらかじめ設定した固定のレスポンス です。1回目の結果を保存して返しているわけではないので、送った金額が違っても、キーが違っても、同じ決済IDと金額が返ります。また、「本文が違う」「処理中」は前のリクエストを見て判定しているのではなく、決まったキーの値で再現しています。サーバーの実装を確かめる道具ではなく、クライアントが400・409・422や再送の結果を正しく扱えるかを確かめるためのモック として使ってください。モックAPIそのものの考え方は モックAPIとは?必要な理由から作り方、無料ツールの選び方まで で解説しています。

まとめ

まずは自分のAPIの中で「2回実行されると困るPOST」を洗い出し、キーを受け付けているか、クライアントが再送で同じキーを使っているかを確かめるところから始めてみてください。

同じキーの再送やタイムアウト後の再送を、本物の決済サービスを使わずにテストしてみませんか。ScenarioMockなら、ヘッダーの値ごとに応答を切り替えるモックAPIを数分で作れます。

冪等なAPIのモックを無料で作る