第12章:冪等キー設計のルール(スコープ/TTL/再利用禁止)⏳🧷
この章のゴール🎯✨
- 冪等キーを「どこまで同じ扱いにするか(スコープ)」を決められる👤🔑
- 冪等キーを「いつまで覚えておくか(TTL)」を決められる🕒🧠
- 冪等キーの「再利用禁止ルール」を作れて、事故(別内容なのに同じキー)を防げる🚫💥
- そのままDBテーブル設計(たたき台)を書ける🗃️✍️

まず結論:冪等キー設計の“三大ルール”🔑🔁📌
① スコープ:キーは「誰の」「どの操作の」キー?👤➡️🧾
同じ冪等キーでも、誰が使ったかで意味が変わるよね? だから「冪等キーだけ」をユニークにするのは危険⚠️
基本はこの形が強い💪
- (ユーザー識別子 など)+(API操作の種類 など)+(冪等キー)
例:
- userId 単位でユニーク(同じ userId の中で key が重複しない)
- さらに「注文作成」などの操作単位も混ぜる(別エンドポイントでの事故を減らす)
② TTL:いつまで“同じ結果を返す”の?🕒🔁
冪等キーはずーっと保存しない🙅♀️ 保存しっぱなしだと、コストも増えるし、運用も重くなる📦💸
でも短すぎると、時間がたってからのリトライで二重作成が起きる😱 なので「リトライが起きうる期間」を想像して決めるのがコツ🧠✨
実例として、Stripeはキーを最大255文字まで扱えて、少なくとも24時間後に自動削除できる(古いキーは削除され、削除後に同じキーを使うと新規扱いになる)という運用を案内してるよ📌 (Stripeドキュメント) Adyenはキーが最低7日間有効(company account単位でユニーク)という運用を案内してるよ📌 (docs.adyen.com)
③ 再利用禁止:同じキーは「同じ内容」にしか使っちゃダメ🚫🧾
ここが一番事故るポイント💥 同じキーで、内容が違うリクエストを送っちゃうと…
- サーバーは「同じ処理だよね?」って思って前の結果を返す
- でもクライアントは「別の注文のつもり」 → ぐちゃぐちゃ😵💫
だから王道はこれ👇
- 同じキーで来たリクエストは、パラメータ(内容)が同一かチェック
- 違ったらエラーにする(Stripeも「元のリクエストと同じパラメータか比較して、違えばエラーにする」と説明してるよ) (Stripeドキュメント)
さらに、IETFのIdempotency-Keyヘッダーの標準化ドラフトでも、POST/PATCHなどを安全にリトライ可能にするためのヘッダーとして整理されてるよ📌 (IETF Datatracker)
12.1 スコープ設計:何を「同じ操作」とみなす?🧩🔍
スコープ候補(強さ順)💪✨
A. 「userId + idempotencyKey」方式(よくある王道)👤🔑
- 同一ユーザーの中ではキー重複禁止
- 別ユーザーなら同じキーでもOK
- マルチテナントなら tenantId も混ぜるのが鉄板🏢
メリット:シンプルで強い😊 注意:同一ユーザーが別エンドポイントで同じキーを使う事故がありえる
B. 「userId + endpoint(または operation) + idempotencyKey」方式(事故りにくい)👤🧾🔑
- 「注文作成」と「支払い確定」で同じキーを使っても衝突しにくい👍
おすすめ:教材のミニ注文APIではこれが扱いやすい✨
C. 「グローバルで idempotencyKey をユニーク」(非推奨寄り)🌍⚠️
- “世界で一意なキー生成”が前提になって、運用が重い
- 事故ったときの影響範囲も広い😵
スコープ設計のチェック質問✅📝
- 同じユーザーが、別の操作で同じキーを使う可能性ある?🤔
- モバイル/ブラウザ/バッチなど複数クライアントがある?📱💻
- 「会社単位でユニーク」みたいな境界が必要?🏢 (例:Adyenは会社アカウント単位でユニークにする運用) (docs.adyen.com)
12.2 TTL設計:どれくらい覚えておく?🕒📦
TTLを決める“考え方”🧠✨
TTLは「適当に1日!」じゃなくて、だいたいこの材料で決めると安全✅
- クライアントのリトライ期間(どれくらい粘る?)🔁
- タイムアウトや通信不安定が起きる現実(数分〜数時間ありえる)🌧️
- 非同期処理が混ざるなら、完了までの最長時間も考える⏳
- 監査・問い合わせ対応(「あの注文どうなった?」)の都合も少し入れる📞
有名どころの“目安”感📌
- Stripe:キーは少なくとも24時間後に自動削除でき、削除後に同じキーを使うと新規扱いになる(また、同じキーでパラメータが違うとエラー) (Stripeドキュメント)
- Adyen:キーは最低7日有効 (docs.adyen.com)
- PayPal:リクエストIDを一定期間保持し、その間はリトライ可能として説明 (developer.paypal.com)
教材ミニAPIのおすすめTTL(迷ったら)🍰✨
- 同期処理メイン:24時間(扱いやすい&現実に強い)🕛
- 非同期が混ざる:完了想定時間 + 余裕(例:最大2時間なら 24時間でOK、最大2日なら 3〜7日も検討)📮
- 超重要(決済級)で問い合わせも多い:7日も現実的(ただし保存コスト↑)💳📈
12.3 再利用禁止:同じキーは“同じ内容”だけ!🚫🧾
どうやって防ぐ?(超おすすめのやり方)🔒✨
リクエストの内容をハッシュ化して保存しておくのが王道👍

-
初回:
- (userId, operation, key)でレコード作る
- requestHash も保存する
-
2回目以降:
- 同じ(userId, operation, key)を発見
- requestHash が同じなら “リプレイ”(同じ結果を返す)🔁
- requestHash が違うなら “不正再利用”(エラー)💥
Stripeも「最初のリクエストと同じパラメータか比較し、違えばエラーにする」運用を説明してるよ📌 (Stripeドキュメント)
12.4 DBテーブル案(たたき台)🗃️✍️
ここでは「後で第13章の“結果保存”につながる形」で作るよ📦📤
最低限ほしい列(ミニマム)✅
- scope用:userId(+必要ならtenantId)
- operation(例:createOrder)
- idempotencyKey
- requestHash(再利用禁止チェック用)
- status(processing/succeeded/failed)
- createdAt / expiresAt
実務寄りにするなら(結果リプレイ用)✨
- responseStatus
- responseBody(JSON)
- responseHeaders(JSON)
- updatedAt
SQL例(PostgreSQL想定)🐘
CREATE TABLE idempotency_records (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NULL, -- 必要なら(マルチテナント用)
user_id TEXT NOT NULL, -- スコープ
operation TEXT NOT NULL, -- 例: "createOrder"
idem_key TEXT NOT NULL, -- Idempotency-Key
request_hash TEXT NOT NULL, -- 再利用禁止チェック用
status TEXT NOT NULL, -- "processing" | "succeeded" | "failed"
response_status INT NULL,
response_body JSONB NULL,
response_headers JSONB NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
-- スコープ+キーの一意制約(超重要!)
CREATE UNIQUE INDEX ux_idem_scope_key
ON idempotency_records (COALESCE(tenant_id, ''), user_id, operation, idem_key);
-- 掃除用(TTL)
CREATE INDEX ix_idem_expires_at
ON idempotency_records (expires_at);
12.5 TypeScriptでの“設計イメージ”(超ミニ)🧠💻
リクエスト内容のハッシュ化(例)🔐
import crypto from "node:crypto";
type CreateOrderBody = {
productId: string;
quantity: number;
};
function stableStringify(obj: unknown): string {
// 教材用の簡易版:キー順を安定させる(実務はライブラリ利用もアリ)
return JSON.stringify(obj, Object.keys(obj as any).sort());
}
function hashRequest(body: CreateOrderBody): string {
const s = stableStringify(body);
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}
再利用禁止チェックの流れ(擬似コード)🔁✅
type IdemStatus = "processing" | "succeeded" | "failed";
type IdemRecord = {
userId: string;
operation: string;
idemKey: string;
requestHash: string;
status: IdemStatus;
responseStatus?: number;
responseBody?: unknown;
expiresAt: Date;
};
async function handleCreateOrder(userId: string, idemKey: string, body: CreateOrderBody) {
const operation = "createOrder";
const requestHash = hashRequest(body);
const existing = await findIdemRecord(userId, operation, idemKey);
if (!existing) {
// 初回:先にレコード作成(同時実行にも強くなる)
await insertIdemRecord({
userId,
operation,
idemKey,
requestHash,
status: "processing",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const result = await actuallyCreateOrder(body);
await markSucceeded(userId, operation, idemKey, {
responseStatus: 201,
responseBody: result,
});
return { status: 201, body: result };
}
// 2回目以降:同じ内容かチェック
if (existing.requestHash !== requestHash) {
// “同じキーで別内容” → 事故防止で弾く
return { status: 409, body: { message: "Idempotency key reused with different request." } };
}
// 同じ内容なら結果を返す(第13章で本格化)
if (existing.status === "succeeded") {
return { status: existing.responseStatus ?? 200, body: existing.responseBody };
}
// processing中なら「処理中」と返す設計もアリ(第20章で詳しく)
return { status: 202, body: { message: "Processing. Retry with the same Idempotency-Key." } };
}
// ↓ ここはDBアクセス層(今回は形だけ)
declare function findIdemRecord(userId: string, operation: string, idemKey: string): Promise<IdemRecord | null>;
declare function insertIdemRecord(rec: IdemRecord): Promise<void>;
declare function markSucceeded(
userId: string,
operation: string,
idemKey: string,
data: { responseStatus: number; responseBody: unknown }
): Promise<void>;
declare function actuallyCreateOrder(body: CreateOrderBody): Promise<unknown>;
ポイント💡
- 先に「processing」で確保する(同時実行に強くなる)🔒
- requestHash を比べて「別内容」を拒否する🚫
- 成功済みなら結果を返す(レスポンスキャッシュ型)📦
- TTL掃除のため expiresAt を入れる🧹🕒
12.6 よくある事故パターン集😱➡️✅
事故①:スコープが弱くて別ユーザー同士が衝突💥
- 「idempotencyKeyだけユニーク」にしてると起きがち ✅ userId(+tenantId)を混ぜる👤🏢
事故②:TTLが短すぎて“翌日リトライ”で二重作成😵
✅ リトライ期間+運用実態から決める (例:Stripeは少なくとも24時間後に削除できる運用を案内) (Stripeドキュメント)
事故③:同じキーを別内容に使って、意図しない結果が返る🌀
✅ requestHash(またはパラメータ比較)で弾く (Stripeはパラメータ比較して不一致ならエラーにする運用を説明) (Stripeドキュメント)
事故④:キーに個人情報を入れてしまう🫣
- 例:メールアドレス、氏名など ✅ UUIDみたいなランダム文字列にする🔑✨ (Stripeもキー生成はUUID v4などランダムを推奨し、長さ上限も示してる) (Stripeドキュメント)
12.7 ミニ演習📝✨(この章の手を動かすやつ!)
演習1:あなたの「スコープ」を決めよう👤🔑
次を埋めてね✍️
- スコープの単位: userId / tenantId / operation をどう使う?
- ユニーク制約: ( , , , idemKey )
演習2:TTLを決めよう🕒
- 想定する最大リトライ時間:___時間
- TTL:___時間(または___日)
- その理由:______________
ヒント:
- Stripeは少なくとも24時間後に削除できる運用を案内 (Stripeドキュメント)
- Adyenは最低7日有効の運用を案内 (docs.adyen.com)
演習3:再利用禁止ルールを書こう🚫🧾
仕様文(日本語でOK)を1〜2文で✍️ 例の形:
- 「同じ冪等キーは同一内容のリトライにのみ使用できる。内容が異なる場合は 409 を返す。」
12.8 AI活用🤖✨(この章で使うと強い!)
① スコープ設計レビュー🔍
AIへの投げ方例💬
- 「このAPIの冪等キーのスコープ案を3つ出して。事故りやすさと運用コストも比較して」
② TTLの妥当性チェック🕒✅
- 「最大リトライが○時間、非同期が最大○時間。TTLの候補を3つ出して、メリデメを書いて」
③ テーブル案レビュー🗃️
- 「このテーブル設計で“同一キー別内容”を防げる?足りない列やインデックスを指摘して」
(注意⚠️) AIはときどき「キー永続保存でOK!」みたいな雑回答をすることがあるから、リトライ期間と保存コストのトレードオフは必ず自分でも確認ね🧠✨
まとめ🎀
- スコープは「ユーザー+操作+キー」が基本で事故りにくい👤🧾🔑
- TTLは「リトライ期間」と「非同期の最長」を材料に決める🕒
- 再利用禁止は requestHash(またはパラメータ比較)で守る🚫🧾
- テーブルは「ユニーク制約」と「expiresAt」が生命線🗃️❤️