Back to list
dev_to 2026年3月21日

Idempotent API の設計:なぜ POST エンドポイントがダミー処理に対応する必要があるのか

Designing Idempotent APIs: Why Your POST Endpoint Needs to Handle Duplicates

Translated: 2026/3/21 7:01:01
http-methodsidempotencyredisapi-designdatabase-constraints

Japanese Translation

ユーザーが「購入」をクリック。何も起こらない。再度クリック。2 回の請求。 GET、PUT、DELETE は冪等性を持つ。POST はそうではない。 ネットワークのリトライ:モバイルアプリがタイムアウト時にリトライ。サーバーは最初の処理を終了している。 ロードバランサーのリトライ:アップストリームタイムアウトが別のバックエンドへのリトライをトリガーする。 ユーザーのダブルクリック:ボタンが不十分に無効化されていない。 冪等性がない場合、各リトライで重複が発生する。 クライアントは UUID を生成し、ヘッダーとして送信。サーバーは処理前に確認する。 POST /api/orders Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 {"product_id": "prod_123", "quantity": 2} サーバー:キーが Redis に存在するかどうかを確認。存在する場合はキャッシュされた応答を返す。存在しない場合は処理し、キャッシュする。 import { Request, Response, NextFunction } from "express"; import Redis from "ioredis"; const redis = new Redis(process.env.REDIS_URL!); const TTL = 86400; // 24 時間 interface CachedResponse { status: number; body: unknown; } export function idempotency() { return async (req: Request, res: Response, next: NextFunction) => { if (req.method !== "POST") return next(); const key = req.headers["idempotency-key"] as string; if (!key) return next(); const cacheKey = `idem:${req.path}:${key}`; // キャッシュの確認 const cached = await redis.get(cacheKey); if (cached) { const r: CachedResponse = JSON.parse(cached); return res.status(r.status).json(r.body); } // 並行処理による重複を防止するロック const lock = await redis.set(`lock:${cacheKey}`, "1", "EX", 30, "NX"); if (!lock) { return res.status(409).json({ error: "Request in progress" }); } // 応答をキャッシュするために干渉 const origJson = res.json.bind(res); res.json = function (body: unknown) { redis.setex(cacheKey, TTL, JSON.stringify({ status: res.statusCode, body })); redis.del(`lock:${cacheKey}`); return origJson(body); }; next(); }; } CREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), idempotency_key UUID UNIQUE NOT NULL, product_id TEXT NOT NULL, quantity INTEGER NOT NULL, total DECIMAL(10,2) NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); INSERT INTO orders (idempotency_key, product_id, quantity, total) VALUES ($1, $2, $3, $4) ON CONFLICT (idempotency_key) DO NOTHING RETURNING *; 不一致の場合、既存の行をクエリして返す。UNIQUE 制約により、同時負荷下でも重複が発生しない。 Stripe の冪等性はゴールドスタンダードである: 1. キーのスコーピング:キーは API キーごとにスコープされ、グローバルではない。 2. リクエストのフィンガープリンティング:異なるパラメータでキーを再利用すると 400 が返され、沈黙したキャッシュヒットではない。 3. 24 時間の TTL:ストレージを回収できるよう短く、リトライに十分長い。 4. 進行中の検出:同じキーが現在処理中である場合、409 が返される。 function validateIdempotencyReuse( cached: CachedRequest, incoming: Request ): void { const hash = crypto.createHash("sha256") .update(JSON.stringify(incoming.body)).digest("hex"); if (cached.bodyHash !== hash) { throw new HttpError(400, "Idempotency key reused with different request body"); } } テスト:重複キー = 同じ応答。異なるボディ = 400。並行 = 1 つが 201、1 つが 409。 サーバーがキーを生成することを決して許さない - クライアントがリトライのアイデンティティを制御する キーのスコープ:idem:user:path:key だけを idem:key にしない 2xx/4xx だけをキャッシュし、5xx (一時的なエラー) はキャッシュしない(再試行すべき) 常に TTL を設定する(24 時間が標準的) マルチステップの操作には DB トランザクションを使用 - idempotency キーを同じトランザクションに含める 冪等な API は、重複請求、注文、メールを防ぐ。 クライアントが一意のキーを送信 サーバーがキーが存在するか確認 確認済み:キャッシュされた応答を返す 新規:処理し、キャッシュし、返す 最初から DB の UNIQUE 制約から始め、Redis で速度を向上させる。Stripe レベルの安全性のためにリクエストフィンガープリンティングを追加する。 ユーザーはダブルクリックする。ネットワークはリトライする。それに設計する。 本稿は、私の Production Backend Patterns シリーズの一部です。さらに多くの実用的なバックエンドエンジニアリングについてはフォローしてください。

Original Content

Designing Idempotent APIs: Why Your POST Endpoint Needs to Handle Duplicates A user clicks Buy. Nothing happens. They click again. Two charges. Same request N times = same result. GET, PUT, DELETE are idempotent. POST is not. Network retries: Mobile app retries on timeout. Server already processed the first request. Load balancer retries: Upstream timeout triggers retry to different backend. User double-clicks: Button not disabled fast enough. Without idempotency, each retry creates duplicates. Client generates a UUID and sends it as a header. Server checks before processing. POST /api/orders Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 {"product_id": "prod_123", "quantity": 2} Server: check if key exists in Redis. If yes, return cached response. If no, process and cache. import { Request, Response, NextFunction } from "express"; import Redis from "ioredis"; const redis = new Redis(process.env.REDIS_URL\!); const TTL = 86400; // 24 hours interface CachedResponse { status: number; body: unknown; } export function idempotency() { return async (req: Request, res: Response, next: NextFunction) => { if (req.method \!== "POST") return next(); const key = req.headers["idempotency-key"] as string; if (\!key) return next(); const cacheKey = `idem:${req.path}:${key}`; // Check cache const cached = await redis.get(cacheKey); if (cached) { const r: CachedResponse = JSON.parse(cached); return res.status(r.status).json(r.body); } // Lock to prevent concurrent duplicates const lock = await redis.set(`lock:${cacheKey}`, "1", "EX", 30, "NX"); if (\!lock) { return res.status(409).json({ error: "Request in progress" }); } // Intercept response to cache it const origJson = res.json.bind(res); res.json = function (body: unknown) { redis.setex(cacheKey, TTL, JSON.stringify({ status: res.statusCode, body })); redis.del(`lock:${cacheKey}`); return origJson(body); }; next(); }; } CREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), idempotency_key UUID UNIQUE NOT NULL, product_id TEXT NOT NULL, quantity INTEGER NOT NULL, total DECIMAL(10,2) NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); INSERT INTO orders (idempotency_key, product_id, quantity, total) VALUES ($1, $2, $3, $4) ON CONFLICT (idempotency_key) DO NOTHING RETURNING *; If conflict, query existing row and return it. The UNIQUE constraint guarantees no duplicates even under concurrent load. Stripe idempotency is the gold standard: 1. Key scoping: Keys scoped per API key, not global. 2. Request fingerprinting: Reusing a key with different params returns 400, not silent cache hit. 3. 24-hour TTL: Short enough to reclaim storage, long enough for retries. 4. In-progress detection: Same key currently processing returns 409. function validateIdempotencyReuse( cached: CachedRequest, incoming: Request ): void { const hash = crypto.createHash("sha256") .update(JSON.stringify(incoming.body)).digest("hex"); if (cached.bodyHash \!== hash) { throw new HttpError(400, "Idempotency key reused with different request body"); } } Test: duplicate key = same response. Different body = 400. Concurrent = one 201, one 409. Never let server generate keys - client controls retry identity Scope keys: idem:user:path:key not just idem:key Only cache 2xx/4xx, not 5xx (transient errors should retry) Always set TTL (24h is standard) Use DB transactions for multi-step ops - idempotency key in same tx Idempotent APIs prevent duplicate charges, orders, and emails. Client sends unique key Server checks if key exists If seen: return cached response If new: process, cache, return Start with DB UNIQUE constraints. Add Redis for speed. Add request fingerprinting for Stripe-level safety. Your users will double-click. Your network will retry. Design for it. Part of my Production Backend Patterns series. Follow for more practical backend engineering. If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.