HTTP Method 的冪等性

HTTP spec 定義了哪些 method 應該是冪等的:

GET    → 冪等(讀取,多次相同)
PUT    → 冪等(整體替換,多次結果相同)
DELETE → 冪等(刪一次和刪兩次結果一樣——資源消失)
PATCH  → 不一定(取決於操作:「設為 active」冪等;「+1」不冪等)
POST   → 通常不冪等(每次建立新資源)

問題出在 POST:建立訂單、發起支付、發送通知——這些都是 POST,都有副作用,都不是天然冪等的。

網路是不可靠的。Client 送出 POST 之後,可能發生:

  1. Request 送到 server,server 處理成功,但 response 在路上丟了
  2. Request 在路上丟了,server 沒收到
  3. Server 在處理中崩潰,不確定有沒有完成

Client 看到 timeout 或 error,不知道是哪種情況,只能重試。如果 server 沒有 idempotency 機制,重試就可能建兩張訂單、扣兩次錢。


Idempotency Key 模式

Stripe 的做法(業界標準):

POST /v1/charges
Idempotency-Key: a8b4f9e2-3c1d-4a5b-8e7f-9d2a1b3c4e5f
Content-Type: application/json

{
  "amount": 2000,
  "currency": "usd",
  "source": "tok_visa"
}

Client 在第一次請求時生成一個唯一的 key(UUID)。如果需要重試,帶同樣的 key。Server 看到這個 key 就知道「這是我處理過的請求,直接回傳上次的結果」。

Server 端實作

// 使用 Redis 儲存 idempotency record
interface IdempotencyRecord {
  status: 'processing' | 'completed' | 'failed';
  response?: { statusCode: number; body: unknown };
  createdAt: number;
}
 
export const idempotencyMiddleware = async (req, res, next) => {
  const idempotencyKey = req.headers['idempotency-key'] as string;
 
  // 只對 POST 和 PATCH 做 idempotency
  if (!['POST', 'PATCH'].includes(req.method) || !idempotencyKey) {
    return next();
  }
 
  // 驗 key 格式(防止惡意構造的 key)
  if (!/^[\w-]{1,64}$/.test(idempotencyKey)) {
    return res.status(400).json({ error: 'Invalid Idempotency-Key format' });
  }
 
  const cacheKey = `idempotency:${req.path}:${idempotencyKey}`;
  const existing = await redis.get(cacheKey);
 
  if (existing) {
    const record: IdempotencyRecord = JSON.parse(existing);
 
    if (record.status === 'processing') {
      // 還在處理中,告訴 client 稍後再試
      return res.status(409).json({ error: 'Request is being processed' });
    }
 
    if (record.status === 'completed' && record.response) {
      // 回傳上次的結果
      res.set('Idempotency-Replayed', 'true');
      return res.status(record.response.statusCode).json(record.response.body);
    }
  }
 
  // 標記為「處理中」,防止並發重複請求
  await redis.set(
    cacheKey,
    JSON.stringify({ status: 'processing', createdAt: Date.now() }),
    'EX', 86400  // 24 小時過期
  );
 
  // 攔截 response,儲存結果
  const originalJson = res.json.bind(res);
  res.json = function (body) {
    redis.set(
      cacheKey,
      JSON.stringify({
        status: res.statusCode < 500 ? 'completed' : 'failed',
        response: { statusCode: res.statusCode, body },
        createdAt: Date.now(),
      }),
      'EX', 86400
    );
    return originalJson(body);
  };
 
  next();
};

Key 的生成與有效期

Client 端生成 key(不是 Server):因為 client 要在重試時帶同一個 key,所以必須是 client 在第一次請求前就生成好。

// Frontend / Client SDK
import { v4 as uuidv4 } from 'uuid';
 
async function createOrder(orderData) {
  const idempotencyKey = uuidv4();  // 每個操作生成一個新 UUID
 
  // 存到 localStorage,方便重試時帶同樣的 key
  localStorage.setItem('pending-order-key', idempotencyKey);
 
  try {
    const response = await fetch('/api/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify(orderData),
    });
 
    localStorage.removeItem('pending-order-key');  // 成功後清除
    return response.json();
  } catch (error) {
    // 下次重試時從 localStorage 取同一個 key
    throw error;
  }
}

有效期設計

  • 太短(1 小時):client 操作慢或網路問題,有效期過了重試就變成新請求
  • 太長(30 天):server 要維護大量歷史記錄
  • 建議:24 小時。Stripe 用 24 小時。

什麼操作需要 Idempotency Key

一定要有

  • 支付、退款、扣款
  • 建立訂單
  • 發送 email / SMS / push notification(重送一封信是大問題)
  • 庫存扣減

可以不要(但有也無妨):

  • 建立草稿(重複建立兩個草稿影響不大)
  • 純查詢操作(GET 天然冪等)
  • 可容忍重複的操作(tag 加倍頂多 UI 有點奇怪)

資料庫層的冪等性

除了 API 層的 idempotency key,資料庫操作本身也可以設計成冪等:

-- INSERT OR IGNORE(SQLite / MySQL)
INSERT OR IGNORE INTO orders (idempotency_key, user_id, amount)
VALUES ('key-xxx', 1, 200);
 
-- ON CONFLICT DO NOTHING(PostgreSQL)
INSERT INTO orders (idempotency_key, user_id, amount)
VALUES ('key-xxx', 1, 200)
ON CONFLICT (idempotency_key) DO NOTHING;
 
-- UPSERT:如果已存在就更新,如果不存在就插入
INSERT INTO feature_flags (name, enabled)
VALUES ('new-feature', false)
ON CONFLICT (name) DO UPDATE SET updated_at = NOW();

這和 Seeder 的冪等性設計 是同一個原則——任何寫入操作,加上 ON CONFLICT DO NOTHING 就變成了可安全重試的冪等操作。


延伸閱讀