べき等キー

API リクエストの重複実行を防ぐために、クライアントが付与する一意な識別子

API耐障害性

べき等キーとは

べき等キー (Idempotency Key) は、API リクエストに付与する一意な識別子 (UUID) で、同じリクエストが複数回送信されても 1 回だけ処理されることを保証する仕組みである。ネットワークタイムアウトでクライアントがリトライした場合に、二重課金や二重注文を防ぐ。

Stripe が決済 API で Idempotency-Key ヘッダーを採用したことで広く知られるようになった。決済に限らず、副作用を持つ POST/PUT リクエスト全般で使われるパターンである。ヘッダー名自体の仕様化は IETF の HTTPAPI ワーキンググループで検討されてきたものの、2026 年 8 月時点では Internet-Draft (draft-ietf-httpapi-idempotency-key-header・第 07 版 = 2025 年 10 月発行) が失効 (expired) した状態で、RFC には至っていない。キーの有効期間や重複時の応答には実装差が残るため、利用先の API ドキュメントで必ず確かめる。

なぜ必要か

分散システムでは、リクエストが「成功したが応答が返らなかった」状況が頻繁に発生する。

クライアント → POST /payments → サーバー (決済成功)
クライアント ← タイムアウト ← ネットワーク障害
クライアント → POST /payments → サーバー (???)

べき等キーがなければ、サーバーは 2 回目のリクエストを新規として処理し、二重課金が発生する。べき等キーがあれば、サーバーは「このキーは処理済み」と判断し、最初の結果をキャッシュから返す。

実装パターン

実装パターンのコード例を示す。

// クライアント: リクエストにべき等キーを付与
const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/payments', {
  method: 'POST',
  headers: { 'Idempotency-Key': idempotencyKey },
  body: JSON.stringify({ amount: 1000, currency: 'JPY' }),
});

// リトライ時は同じキーを使う
if (!response.ok) {
  const retry = await fetch('/api/payments', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey },  // 同じキー
    body: JSON.stringify({ amount: 1000, currency: 'JPY' }),
  });
}

DynamoDB での実装

DynamoDB の条件付き書き込みで、べき等キーの重複を原子的にチェックできる。

async function processPayment(event: APIGatewayEvent) {
  const key = event.headers['idempotency-key'];
  if (!key) return { statusCode: 400, body: 'Idempotency-Key required' };

  // 1. 決済の前にキーを予約する (先着 1 件だけが成功する)
  try {
    await ddb.send(new PutCommand({
      TableName: TABLE_NAME,
      Item: {
        pk: `IDEMP#${key}`,
        status: 'IN_PROGRESS',
        ttl: Math.floor(Date.now() / 1000) + 86400,  // 24 時間後に自動削除
      },
      ConditionExpression: 'attribute_not_exists(pk)',
    }));
  } catch (e) {
    if ((e as Error).name !== 'ConditionalCheckFailedException') throw e;

    // 2. 予約済み = 同じキーの先行リクエストがある
    const existing = await ddb.send(new GetCommand({
      TableName: TABLE_NAME,
      Key: { pk: `IDEMP#${key}` },
    }));
    if (existing.Item?.status === 'IN_PROGRESS') {
      return { statusCode: 409, body: 'request in progress' };
    }
    return JSON.parse(existing.Item!.response);  // 完了済み = 同じ結果を返す
  }

  // 3. 予約できた 1 件だけが決済を実行し、結果を書き戻す
  const result = await chargePayment(JSON.parse(event.body));
  await ddb.send(new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { pk: `IDEMP#${key}` },
    UpdateExpression: 'SET #s = :done, #r = :res',
    ExpressionAttributeNames: { '#s': 'status', '#r': 'response' },
    ExpressionAttributeValues: { ':done': 'COMPLETED', ':res': JSON.stringify(result) },
  }));

  return result;
}

ConditionExpression: 'attribute_not_exists(pk)' により、2 つのリクエストが同時に到達しても予約に成功するのは 1 件だけである。ここで順序が要点になる。結果を保存する側にだけ条件を付けると、保存は 1 件に収れんしても決済そのものは両方が実行してしまう。防ぎたいのは書き込みの重複ではなく副作用の重複なので、キーの予約は必ず副作用の前に置く。

予約に失敗した側は ConditionalCheckFailedException を受け取り、レコードを読み直して、先行リクエストが処理中なら 409 を、完了済みなら保存済みの結果を返す。なお決済が例外で落ちたときに IN_PROGRESS のまま放置すると、TTL が切れるまで同じキーが 409 を返し続ける。失敗時はレコードを削除するか、失敗として記録して再試行できる状態に戻しておく。

設計上の考慮点

TTL の設定

べき等キーのレコードを永久に保持するとストレージが膨張する。Stripe は「少なくとも 24 時間を経過したキーは自動削除され、削除後に同じキーが使われた場合は新しいリクエストとして処理する」と明記しており、多くの実装も 24〜72 時間程度の TTL を置く。この保持期間はサーバー側の約束なので、クライアントのリトライを何時間も続く設計にすると、窓を越えた再送が新規処理として通ってしまう。リトライの打ち切りは TTL より十分短く決める。

キーの生成はクライアントの責務

べき等キーはクライアントが生成する。サーバーが生成すると、リトライ時に「前回のキーが何だったか」をクライアントが知る手段がない。UUID v4 が一般的だが、ビジネスロジックに基づくキー (注文 ID + 操作種別) を使うこともある。なお GET と DELETE はメソッド自体がべき等なので、Stripe はこれらのリクエストにキーを付けないよう案内している。

進行中のリクエストの扱い

最初のリクエストが処理中に 2 回目のリクエストが到達した場合、409 Conflict を返して「処理中」であることを伝える。クライアントは一定時間後にリトライする。前述の Internet-Draft もこの状況の応答として 409 を推奨し、本文で問題内容を返す例 (application/problem+json) を示している。

見落としやすいのは、どの失敗でキーを使い切ったことにするかである。Stripe は結果を保存するのがエンドポイントの実行を開始した後だと明記しており、パラメータの検証エラーや並行実行中のリクエストとの衝突ではべき等な結果が残らない。つまりこれらの応答を受けたクライアントは、同じキーのまま送り直してよい。逆に、決済のように副作用が始まった後の失敗を「キーは未使用」として扱うと、二重処理に戻ってしまう。

同じキーで中身が違うとき

キーは同じなのに本文が違うリクエストは、リトライではなく事故である。前述の Internet-Draft は同じべき等キーを異なるペイロードで使い回した場合に 422 (Unprocessable Content) を返すよう推奨し、Stripe も受け取ったパラメータを 1 回目と比べて一致しなければエラーにする。サーバー側の実装では、キーと一緒に本文のハッシュ (Internet-Draft のいう idempotency fingerprint) を保存して突き合わせる。素通しすると、1 回目の金額で処理された結果を 2 回目の金額に対する応答として返すことになる。

Lambda Powertools の Idempotency

AWS Lambda Powertools には Idempotency ユーティリティが組み込まれており、DynamoDB ベースのべき等性を数行で実装できる。予約と結果保存の順序、処理中の検出 (同一ペイロードの並行呼び出しには IdempotencyAlreadyInProgressError)、TTL による失効までを引き受けてくれる。

ただし既定の鍵はイベント全体のハッシュであり、Idempotency-Key ヘッダーではない。ヘッダーを鍵にしたい場合や、毎回変わるタイムスタンプを鍵から外したい場合は eventKeyJmesPath で対象を絞る。記録の保持は既定で 3600 秒なので、決済のように長い窓が必要なら expiresAfterSeconds を延ばす。

べき等キーの実装方法

べき等キーの実装方法を以下にまとめる。

方法ストレージTTL
DynamoDB条件付き書き込みTTL で自動削除
RedisSET NX EX自動期限切れ
RDBUNIQUE 制約手動削除

全体像を把握するには関連書籍も有用。

この記事は役に立ちましたか?

関連用語

関連する記事