APIにおけるイデモピエンシー — なぜPOSTが2回実行されたのかとその解決策
2回の決済、2つの注文、3回のユーザー作成 — これらは同じAPI契約の欠如による症状です。ここでは、イデモピエンシーの意味、POSTがそれを破壊する理由、そしてイデモピエンシーキーがそれを解決する方法を説明します。
ユーザーが「今すぐ支払」をクリックしました。スピンが回り、ネットワークタイムアウトが発生しました。リトライロジックが動作し、チャージが通った後、元のリクエストもサーバー側で完了し、またチャージが通ったのです。顧客のアカウントから$200が引き落とされ、翌朝にサポートチケットが待っています。
これは珍しいエッジケースではありません。idempotencyを考慮しなかった結果、必要になったときに起こるものです。これを解決しましょう。
idempotencyが実際に意味すること
この語は数学から来ています。操作がidempotentであるとは、その操作を複数回適用しても、一度適用した結果と同じになるということです。 f(f(x)) = f(x).
APIの観点では、同じエンドポイントに同じ意図をN回呼び出すと、システムは一度呼び出したときと同じ状態に留まるべきです。応答はキャッシュされた結果である可能性がありますが、副作用——データベースの書き込み、チャージ、メール——は一度だけ起こるべきです。
これはサーバーが にのみ制限します。これはサブドメインを含む範囲を広げます。サブドメインアクセスが必要ない場合はこの属性を省略してください。、返すものだけではなく、それに関する保証です。
HTTPメソッド:誰が安全で誰が安全でないか
HTTP仕様は、特定のメソッドをidempotentであると定義しています。ここに実用的な分解を示します。
| 方法 | idempotentか? | なぜ |
|---|---|---|
GET | ✅ はい | 読み取り専用。副作用は定義上存在しません。 |
HEAD | ✅ はい | GETと同じで、ボディが返されません。 |
PUT | ✅ はい | 「このリソースをちょうどこの状態に設定する。」2回呼び出すと結果は同じになります。 |
DELETE | ✅ はい | リソースは最初の呼び出し後に消滅します。その後の呼び出しでは何も削除する対象がありません。(ステータスコードは異なるかもしれない——204 vs 404——しかしサーバーの状態は変化しません。) |
POST | ❌ いいえ | 「このペイロードを処理する。」これはサーバーによって意味が決まります。2つのPOSTは通常、2つのリソースを作成または2つの副作用を引き起こします。 |
PATCH | ⚠️ 依存する | 相対更新("increment count by 1")はidempotentではありません。絶対更新("set count to 5")はidempotentです。実装によってどちらが適用されるかが決まります。 |
問題は、実際のビジネス操作——支払いを請求、注文を作成、通知を送信——は自然にPOSTにマッピングされるため、POSTはデフォルトでidempotencyの保証を提供しません。
得られるシーケンスは
ここに典型的な失敗モード、ステップごとに示します。
Client Network Server
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓
|<-- (connection drops) ---| response queued
|
| [retry logic kicks in]
|
|--- POST /payments ------>| |
| |--- (delivered) -------->|
| | processing...
| | card charged ✓ (again)
|<------- 200 OK ----------|<------------------------|
|
[client sees: one charge. server did: two charges]
クライアントは最初の応答を見ませんでした。クライアントの視点ではリクエストが失敗しました。サーバーの視点では2回成功しました。これは分散システムの古典的な問題——クライアントとサーバーが実際に起こったことを異なっています。
これは単に決済処理者だけではありません。注文作成、ユーザー登録、メール送信、在庫予約——「2回実行」が実際の影響を持つすべての場面に当てはまります。
idempotencyキー:Stripeのパターン
Stripeはこのアプローチを人気を博し、現在の多くの決済APIがそれを採用しています。クライアントはリクエストを送信する前にユニークなキーを生成し、ヘッダーに添付します。サーバーはそのキーを使って重複を防ぎます。
クライアント側:
// Generate once, before any retry loop
const idempotencyKey = crypto.randomUUID();
async function chargeWithRetry(amount, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // same key on every retry
},
body: JSON.stringify({ amount, currency: 'usd' }),
});
if (response.ok) return await response.json();
// Don't retry on client errors (4xx)
if (response.status < 500) throw new Error(`Client error: ${response.status}`);
} catch (err) {
if (attempt === retries - 1) throw err;
await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
}
}
}
キーは生成されなければならない レコードを確認してください。 リトライループ——それが全体の目的です。各試行で新しいUUIDを生成すれば、メカニズム全体が失われます。
UUID v4はここではよく機能します。テスト中に迅速に生成する必要がある場合に、 IO Tools' UUID生成器 はライブラリをインストールせずに複数のUUIDを生成できます。
サーバー側:応答を保存し、繰り返しで返す
サーバーの役割は概念的にシンプルです:このキーが既に見られていたら、保存された応答を返し、そうでなければ処理し、保存します。
// Express + Redis
app.post('/api/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (idempotencyKey) {
const cached = await redis.get(`idem:${idempotencyKey}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
}
const result = await paymentProcessor.charge(req.body);
const responseBody = { id: result.id, status: result.status };
if (idempotencyKey) {
// 24-hour TTL — same window Stripe uses
await redis.setex(
`idem:${idempotencyKey}`,
86400,
JSON.stringify({ status: 200, body: responseBody })
);
}
res.json(responseBody);
});
いくつかの重要な詳細があります:
- 失敗もキャッシュする。 チャージが失敗(カード拒否)した場合、その失敗応答も保存します。そうでなければ、リトライはすでにエラーを返したリクエストに対して再びチャージを試みます——これはあなたが避けたいことなのです。
- ボディの整合性を検証する。 Stripeは同じキーを異なるリクエストパラメータで再利用すると422 Unprocessable Entityを返します。これは、誤って異なる操作でキーを再利用したバグを検出するのに適しています。
- 進行中の重複を処理する。 同じキーを持つ2つのリクエストが同時に到着する場合、協調が必要です——1つを処理し、もう1つに対して409を返す、または分散ロックを使用します。Redis SETNXはこのパターンの一般的な方法です。
- 適切なTTLを設定する。 期限切れ後、キーは再利用され、同じキーを持つ新しいリクエストは新しいものとして扱われます。無限に保存しないでください——キャッシュが膨張します。
クライアント側のバグでPOSTが2回実行される
idempotencyキーはネットワーク層のリトライに対して保護します。クライアントが2つの独立したリクエストを発行する場合に役立ちません。よく見られる原因は次の通りです:
- ダブルクリック。 ユーザーがクリックし、何も起こらず、もう一度クリックします。最初のクリック時にボタンを即座に無効にし、応答が返るまで待つのではなく、その間のギャップが2回目のクリックにちょうど当たるのです。
- React StrictModeによる2回の実行。 React 18 Strict Modeは開発環境で効果を2回実行してバグを表面化します。POSTを実行する場合、
useEffectにクリーンアップがない場合、開発環境では重複したリクエストが見られます。これは生産環境では起こりませんが、実際の問題を隠す可能性があります。 - ブラウザフォームの再送信。 送信 → ナビゲート → 戻る → 進む。いくつかのブラウザは「再送信?」とプロンプトし、いくつかはそのまま実行します。POST-Redirect-GETパターン(POST後にリダイレクトを返す)はこれを完全に排除します。
- イベントハンドラにおける競合状態。 クリックと迅速なEnterキー入力が、最初の応答が返る前に両方submitハンドラをトリガーし、フォームを無効にします。
- モバイルアプリのバックグラウンドリトライ。 iOSおよびAndroidのバックグラウンドフェッチまたはネットワーク層のリトライロジックは、アプリがすでに発行したリクエストを繰り返す可能性があります。ユーザーの意図の瞬間にidempotencyキーを生成し、必要であればローカルに保持し、成功を確認後に削除します。
代替案として考慮すべきもの:UUID URLへのPUT
APIの両端をコントロールしている場合、リソース作成に構造的にきれいな代替案があります:クライアントがリソースIDを割り当てし、PUTを使用するようにします。
# Instead of this (POST, not idempotent):
POST /orders
{"amount": 99.00, "items": [...]}
# Do this (PUT to client-generated UUID, idempotent by spec):
PUT /orders/7f3b9c2a-4e5d-4f8b-9a1c-2d3e4f5a6b7c
{"amount": 99.00, "items": [...]}
PUTはHTTP仕様によりidempotentです——「このリソースをこの状態に設定する」という意味は、リソースが存在する場合、同じPUTを繰り返しても追加効果がないということです。サーバーはその重複を INSERT ... ON CONFLICT DO NOTHING または同等の方法で処理します。
このパターンは注文作成、ドラフト管理、IDがリトライ時に整合性を保つ必要があるリソースに特に適しています。ただし、IDが第三者(決済処理者)によって割り当てられる場合や、副作用がサーバー側でIDを知る前に発生する場合には機能しません。
Stripeの実際の実装の様子
Stripeの idempotency keyドキュメント を完全に読む価値があります。実際の運用で重要ないくつかの詳細は次の通りです:
- キーのフォーマット: 255文字以内の文字列で、Stripeアカウントにスコープされています。2つの異なるアカウントが同じ文字列を使用しても互いに干渉しません。
- 24時間のウィンドウ: キーは24時間後に期限切れになります。期限切れ後のリトライは新しいリクエストと扱われます——長期間のワークフローを構築している場合に知っておくと良いです。
- ボディの不一致 = 422: 同じキー、異なるパラメータ → Stripeは422 Unprocessable Entityを返します。これは正しい動作であり、誤ってキーを再利用したバグを検出します。
- 同時処理の重複: 同じキーを持つ2つのリクエストが同時に到着した場合、Stripeは1つを処理し、もう1つに対して409 Conflictを返します。409をリトライする際、短い遅延を加えます。
- キャッシュされた失敗: チャージが失敗(カード拒否)した場合、Stripeはその失敗をキャッシュします。同じキーでリトライすると、同じ拒否を返します。新しいキーを生成して別のカードを試行する必要があり、これは正しい行為です——前の試行は完全かつ意図された操作だったからです。
完全な統合テストセットなしでidempotencyをテストする
行動を検証する最も速い方法は、同じキーで2回リクエストを送信し、2回目の呼び出しでキャッシュされた応答が返り、副作用が再び発生しないことを確認することです。 IO Tools' cURLコマンドビルダー は、カスタムヘッダー——包括 Idempotency-Key ——を含むリクエストを構築するのに便利で、cURLフラグの構文を覚える必要がありません。
カバーすべきシナリオ:
- 同じキー + 同じボディ、TTL内 → キャッシュされた応答が返され、副作用が1回だけ発生
- 同じキー + 異なるボディ → 422(または選択された衝突ステータス)
- TTLが経過した後の同じキー → 新しいリクエストとして扱われる
- キーが提供されていない → 通常通り処理(事前にキーが必須かオプションかを決定しておく)
- 同じキーを持つ2つの同時リクエスト → 1つが処理され、1つが409を返す
短い要約
POSTはidempotentではないため、リクエスト送信と応答受信の間に「重複した副作用」が存在します。解決策は複雑ではありません:リトライループの前にUUIDを生成し、ヘッダーとして送信し、サーバー側でそのヘッダーをキーとした応答をキャッシュします。難しい部分は詳細——失敗のキャッシュ、同時重複の処理、適切なTTLの選定——ですが、基本的なパターンは、重要となるエンドポイントに簡単に追加できるほどシンプルです。
最初の生産トラフィックが到着する前にidempotencyキーのサポートを追加してください。2回のチャージ事故後にそれを追加するのは、非常に悪いタイミングです。
恵 スコアボードが到着しました!
スコアボード ゲームを追跡する楽しい方法です。すべてのデータはブラウザに保存されます。さらに多くの機能がまもなく登場します!
