第28章:冪等性(同じ要求が何回来ても壊れない)🧷✅
結論1行 ✍️
**「同じ“やり直し”が何回届いても、サーバーの結果が1回と同じになるように、Idempotency-Key(冪等キー)+重複排除を入れる」**🧷✨
1) この章でできるようになること 🎯
- 「二重送信」「リトライ」「タイムアウト後の再送」が起きても壊れない理由が説明できる🧠✨
POST /ordersに 冪等キーを導入して、同じリクエストを2回送っても 注文が1回分になるようにできる🛒✅- 同じキーなのに中身が違う事故(超ありがち😱)を防げる🔒
- テストで「同一キー2回」を自動で検証できる🧪🤖
2) そもそも「冪等」って何?🧠
ざっくり言うと…
同じリクエストを何回やっても、サーバー側の“最終結果”が1回と同じなら冪等です🧷✅
HTTPの世界では、PUT や DELETE は「同じ操作を何回しても結果が同じになりやすい」= 冪等なメソッドとして扱われます(定義もあります)📚✨ (RFCエディター)
でも…!
POSTは普通「作る」ので、同じPOSTを2回やると2個できちゃうことが多い😵 → だからPOSTを安全にするには 冪等キーが定番テクになります🔑 (Postman Blog)
3) 冪等キー(Idempotency-Key)って何?🔑
POST や PATCH みたいな「本来は冪等じゃない操作」を、**“やり直しに強くする”**ためのヘッダーが Idempotency-Key です🧷✨
IETF(標準化の団体)でも、POST/PATCH を **fault-tolerant(失敗に強く)**するために使える、という方向で整理されています📄 (IETF Datatracker)
超ざっくり動き 🍩
- クライアントが
Idempotency-Key: なんか一意の文字列を付けて送る - サーバーはそのキーを保存する
- 同じキーがまた来たら、「前と同じ結果」を返す(二重実行しない)
Stripeみたいな決済APIでもこの方式が有名で、 「キーは十分ランダムに」「一定時間(例:24h)保持」「同じキーで違う内容が来たらエラー」みたいな運用が紹介されています💳🧠 (docs.stripe.com)

4) どこが落とし穴?😱(ここ超大事)
冪等化で事故りやすいポイントを先に潰すよ〜🧯✨
落とし穴A:同じキーで“違う内容”を送っちゃう 🧨
例:
- 1回目:
amount=1000 - 2回目:同じ
Idempotency-Keyでamount=2000
これ、通すと地獄です👻 だからサーバー側で 「同じキーなら、リクエスト内容も同じじゃないとダメ」 をチェックします✅(Stripeもこの方針) (docs.stripe.com)
落とし穴B:キーの保存が “メモリだけ” 🫠
サーバー再起動したら忘れて、また二重実行します🔥 教材ではまず メモリ実装で感覚を掴むけど、実戦では DB/Redis に置くのが基本だよ〜🏗️✨
落とし穴C:並行リクエスト(同時に2回来る)⚔️
ほぼ同時に同じキーが飛ぶと、 「まだ保存してないから両方実行」が起きがち😵 → キー単位のロックか DBのユニーク制約で守ります🔒✨
5) ハンズオン:同じ注文IDを2回送っても1回分にする 🛒🧷
ここから実装〜!💪✨
今回は POST /orders を冪等化します。
この章のルール(教材の都合)📌
Idempotency-Keyが無い注文は受け付けない(400)- 同じキーの再送は、前回と同じレスポンスを返す
- 同じキーで内容が違うなら、
409(衝突)を返す
6) 依存パッケージ(例)📦✨
APIが Fastify の場合の例です(軽くて人気)⚡ Fastify は npm で継続的に更新されていて、直近でも v5系が最新として配布されています📦 (npm)
PowerShell(例)👇
cd apps/api
npm i fastify
npm i -D typescript tsx @types/node
npm i async-mutex
※
fetch()は Node.js の組み込みで使えます(Undiciベース)🌊 (undici.nodejs.org) ※ Node.js は v24 が Active LTS として案内されています🛡️ (nodejs.org) ※ TypeScript は npm 上で 5.9.3 が最新として表示されています📘 (npm)
7) 実装:IdempotencyStore(重複排除の心臓部)🫀🔑
apps/api/src/idempotencyStore.ts
import { Mutex } from "async-mutex";
import crypto from "node:crypto";
export type SavedResponse = {
statusCode: number;
headers?: Record<string, string>;
body: unknown;
};
type RecordStatus = "processing" | "completed";
type IdempotencyRecord = {
id: string; // scope + "::" + key
scope: string; // 例: "POST /orders"
key: string; // Idempotency-Key
requestHash: string; // リクエスト内容が同じかチェック用
status: RecordStatus;
createdAt: number;
response?: SavedResponse;
};
export function sha256OfJson(value: unknown): string {
// JSON.stringify は順序差が出ることがあるので、
// この教材では「同一クライアントが同一JSONを再送する」前提の簡易版にするよ 🙆♀️
const json = JSON.stringify(value);
return crypto.createHash("sha256").update(json).digest("hex");
}
export class InMemoryIdempotencyStore {
private records = new Map<string, IdempotencyRecord>();
private mutexes = new Map<string, Mutex>();
constructor(private readonly ttlMs: number) {
// 掃除(TTL切れ削除)🧹
setInterval(() => this.cleanup(), Math.min(60_000, Math.max(5_000, ttlMs / 4))).unref?.();
}
private getMutex(id: string): Mutex {
const existing = this.mutexes.get(id);
if (existing) return existing;
const m = new Mutex();
this.mutexes.set(id, m);
return m;
}
makeId(scope: string, key: string): string {
return `${scope}::${key}`;
}
async runExclusive<T>(id: string, fn: () => Promise<T>): Promise<T> {
const mutex = this.getMutex(id);
return mutex.runExclusive(fn);
}
get(id: string): IdempotencyRecord | undefined {
return this.records.get(id);
}
createProcessing(params: { id: string; scope: string; key: string; requestHash: string }): IdempotencyRecord {
const rec: IdempotencyRecord = {
id: params.id,
scope: params.scope,
key: params.key,
requestHash: params.requestHash,
status: "processing",
createdAt: Date.now(),
};
this.records.set(params.id, rec);
return rec;
}
complete(id: string, response: SavedResponse): void {
const rec = this.records.get(id);
if (!rec) return;
rec.status = "completed";
rec.response = response;
}
private cleanup(): void {
const now = Date.now();
for (const [id, rec] of this.records) {
if (now - rec.createdAt > this.ttlMs) {
this.records.delete(id);
this.mutexes.delete(id);
}
}
}
}
8) 実装:注文リポジトリ(最小)🛒📚
apps/api/src/orderRepo.ts
import crypto from "node:crypto";
export type OrderStatus = "PENDING" | "CONFIRMED" | "CANCELLED";
export type Order = {
id: string;
status: OrderStatus;
total: number;
createdAt: number;
};
export class InMemoryOrderRepo {
private orders = new Map<string, Order>();
create(total: number): Order {
const id = crypto.randomUUID();
const order: Order = {
id,
status: "PENDING",
total,
createdAt: Date.now(),
};
this.orders.set(id, order);
return order;
}
get(id: string): Order | undefined {
return this.orders.get(id);
}
count(): number {
return this.orders.size;
}
// 状態遷移を「二重適用」しないための最小ガード 🧷
transition(id: string, from: OrderStatus, to: OrderStatus): { ok: true } | { ok: false; reason: string } {
const order = this.orders.get(id);
if (!order) return { ok: false, reason: "not_found" };
if (order.status !== from) return { ok: false, reason: `invalid_state:${order.status}` };
order.status = to;
return { ok: true };
}
}
9) 実装:POST /orders を冪等化する ✅🔑
apps/api/src/server.ts
import Fastify from "fastify";
import { InMemoryIdempotencyStore, sha256OfJson } from "./idempotencyStore.js";
import { InMemoryOrderRepo } from "./orderRepo.js";
type CreateOrderBody = {
total: number;
items?: Array<{ sku: string; qty: number }>;
};
export function buildApp() {
const app = Fastify({ logger: true });
// 例:冪等記録は 24h 保持(教材の例)🕒
const idem = new InMemoryIdempotencyStore(24 * 60 * 60 * 1000);
const orders = new InMemoryOrderRepo();
app.post<{ Body: CreateOrderBody }>("/orders", async (req, reply) => {
const scope = "POST /orders";
const key = req.headers["idempotency-key"];
if (typeof key !== "string" || key.length === 0) {
return reply.code(400).send({ error: "Idempotency-Key header is required" });
}
const id = idem.makeId(scope, key);
// リクエスト内容が違うのに同じキーを使ってない?チェック用 🔍
const requestHash = sha256OfJson(req.body);
return idem.runExclusive(id, async () => {
const existing = idem.get(id);
// すでに見たキーだったら… 🧷
if (existing) {
if (existing.requestHash !== requestHash) {
// 同じキーで違う内容は危険なので拒否 🙅♀️
return reply.code(409).send({ error: "Idempotency-Key conflict (payload mismatch)" });
}
if (existing.status === "completed" && existing.response) {
// 1回目と同じ結果を返す ✅
if (existing.response.headers) {
for (const [k, v] of Object.entries(existing.response.headers)) reply.header(k, v);
}
return reply.code(existing.response.statusCode).send(existing.response.body);
}
// processing の場合(ほぼ同時再送など)
return reply.code(202).send({ status: "processing", retryAfterMs: 300 });
}
// はじめてのキー → まず「処理中」を保存 ✍️
idem.createProcessing({ id, scope, key, requestHash });
// ここが「本来の処理」✨(今回は注文を作る)
const order = orders.create(req.body.total);
const body = { orderId: order.id, status: order.status };
// 返す内容も保存(次に同じキーが来たら同じレスポンスを返す)📌
idem.complete(id, { statusCode: 201, body });
return reply.code(201).send(body);
});
});
// デバッグ用:件数確認 👀
app.get("/debug/orders/count", async () => ({ count: orders.count() }));
return app;
}
// ローカル起動用(必要なら)🚀
if (import.meta.url === `file://${process.argv[1]}`) {
const app = buildApp();
app.listen({ port: 3000, host: "127.0.0.1" }).catch((err) => {
app.log.error(err);
process.exit(1);
});
}
10) 動作確認:2回送っても1回分になる?🧪🧷
① 1回目(作成される)✨
PowerShell例👇
$headers = @{ "Idempotency-Key" = "demo-001" }
$body = @{ total = 1200; items = @(@{ sku="A"; qty=1 }) } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -Headers $headers -Body $body -ContentType "application/json"
② 2回目(同じキー・同じ中身)✅
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/orders -Headers $headers -Body $body -ContentType "application/json"
👉 orderId が同じなら勝ち🎉(二重作成されてない)
③ 件数確認 👀
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:3000/debug/orders/count
11) テスト:同一キー2回を自動チェック 🧪🤖
apps/api/src/server.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "./server.js";
test("same Idempotency-Key twice returns same result and creates only one order", async () => {
const app = buildApp();
const payload = { total: 1200, items: [{ sku: "A", qty: 1 }] };
const res1 = await app.inject({
method: "POST",
url: "/orders",
headers: { "Idempotency-Key": "test-001" },
payload,
});
assert.equal(res1.statusCode, 201);
const body1 = res1.json() as { orderId: string; status: string };
const res2 = await app.inject({
method: "POST",
url: "/orders",
headers: { "Idempotency-Key": "test-001" },
payload,
});
assert.equal(res2.statusCode, 201);
const body2 = res2.json() as { orderId: string; status: string };
assert.equal(body1.orderId, body2.orderId);
const count = await app.inject({ method: "GET", url: "/debug/orders/count" });
assert.equal(count.statusCode, 200);
assert.equal(count.json().count, 1);
await app.close();
});
test("same Idempotency-Key with different payload should be rejected", async () => {
const app = buildApp();
const res1 = await app.inject({
method: "POST",
url: "/orders",
headers: { "Idempotency-Key": "test-002" },
payload: { total: 1000 },
});
assert.equal(res1.statusCode, 201);
const res2 = await app.inject({
method: "POST",
url: "/orders",
headers: { "Idempotency-Key": "test-002" },
payload: { total: 9999 },
});
assert.equal(res2.statusCode, 409);
await app.close();
});
実行👇
cd apps/api
node --test
12) “実戦”での設計メモ(ここが強くなる)🏋️♀️✨
教材のメモリ実装を、実戦に寄せるならこう考えるよ〜🧠
- 冪等キーの保存先は DB or Redis(再起動・複数台に耐える)🏗️
scope(POST /orders)も一緒に保存する(別APIでキー衝突しない)🧩- 返したレスポンスも保存して、同じレスポンスを返す(クライアントが安心する)😌
- キーは無限に保存しない:**TTL(保存期限)**を決める(例:24h)🕒 (docs.stripe.com)
- 「同じキーなのに違う内容」は 拒否(事故防止)🧯 (docs.stripe.com)
13) AI活用コーナー 🤖💡(そのままコピペでOK)
プロンプト例1:冪等の設計レビュー👀
- 「この
Idempotency-Key実装で、同時リクエストや再起動時に壊れる点を指摘して。実戦向け改善案を3つ出して」🤖
プロンプト例2:payload mismatch の判定改善🔍
- 「JSONの順序差でも同一判定できる“正規化”の方法を提案して。TypeScriptで最小実装も」🤖
プロンプト例3:テスト追加🧪
- 「
processing(202)になるケースを再現するテストを書いて。どうすれば再現できる?」🤖
14) まとめ ✅✨
POSTはそのままだと二重作成しがち → 冪等キーで防ぐ🧷 (Postman Blog)Idempotency-KeyはPOST/PATCHを失敗に強くするための考え方として整理が進んでるよ📄 (IETF Datatracker)- 同じキー=同じ結果、同じキーで中身が違うのは拒否が安全🔒 (docs.stripe.com)
- 次章(第29章)で「配達保証は現実こうなる👻」に行くので、冪等はその土台だよ〜🧱✨