Recur
API 參考

冪等性

理解冪等性 (Idempotency) 的重要性,以及如何在支付系統中正確實作

冪等性 (Idempotency)

在支付系統中,冪等性是確保系統可靠性的核心設計原則。本文將深入探討什麼是冪等性、為什麼它如此重要,以及如何在你的系統中正確實作。

什麼是冪等性?

冪等性(Idempotency)是一個數學與電腦科學的概念:

一個操作執行一次或多次,產生的結果完全相同。

生活中的冪等操作

  • 電梯按鈕:按一次或按十次,電梯只會來一次
  • 開燈開關:已經開著的燈,再按「開」不會更亮
  • 設定溫度:把冷氣設為 26°C,重複設定還是 26°C

非冪等操作

  • 轉帳:執行兩次 = 轉兩次錢(災難!)
  • 發送訊息:執行兩次 = 收到兩則相同訊息
  • 計數器 +1:執行兩次 = 加了 2

在 API 設計中,GETPUTDELETE 通常是冪等的,而 POST 通常不是。

為什麼支付系統需要冪等性?

網路的不可靠性

在理想世界中,每個請求都會成功送達並收到回應。但現實是:

常見的失敗情境:

情境發生了什麼用戶看到
請求超時伺服器可能已處理完成「請稍後再試」
回應遺失扣款成功但回應沒送達「付款失敗」
網路斷線不確定是否成功轉圈圈後斷線

災難性的後果

當用戶看到「請稍後再試」,他們會怎麼做?再試一次。

如果你的系統沒有冪等性保護:

案例一:重複扣款

第一次請求:扣款 $1,000 ✓(但回應超時)
用戶:「奇怪,沒反應,再按一次」
第二次請求:扣款 $1,000 ✓
結果:用戶被扣了 $2,000

案例二:儲值系統重複入帳

儲值型系統(如點數、遊戲幣、錢包餘額)面臨相反的風險:

Webhook 通知:用戶儲值 $500 成功
系統:餘額 +$500 ✓(但回應超時)
Webhook 重試:同一筆儲值通知再次送達
系統:餘額 +$500 ✓(沒有檢查是否處理過)
結果:用戶只付了 $500,卻得到 $1,000 餘額

這種情況下,損失的是平台方。更糟的是,這類漏洞一旦被發現,可能被惡意利用:

攻擊者:故意讓 webhook 回應超時
系統:不斷重試、不斷入帳
結果:無限刷點數

缺乏冪等性保護,輕則重複扣款引發客訴,重則成為系統漏洞被惡意利用。無論哪種情況,都會造成實質的財務損失和信任危機。

真實世界的重試來源

重複請求不只來自用戶手動重試:

  1. 前端重試邏輯:網路庫自動重試失敗請求
  2. 負載均衡器:認為後端無回應而重試
  3. Webhook 重送:目標伺服器回應太慢
  4. 佇列系統:消費者處理超時後重新派發
  5. 用戶行為:連點按鈕、重新整理頁面

Recur 的冪等性設計

Recur 在 API 和 Webhook 兩個層面都實作了冪等性保護。

API 層:Idempotency-Key Header

對於建立資源的 API(如建立結帳工作階段),Recur 採用類似 Stripe API v2 的設計:

  • Idempotency-Key 是可選的:如果你不傳,系統會自動產生 UUID
  • 所有 POST 請求預設冪等:無論是否傳入 key,相同請求不會重複建立資源
  • 24 小時有效期:相同 key 在 24 小時內會返回相同的結果
# 方式一:讓系統自動產生 key(推薦)
curl -X POST https://api.recur.tw/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{"product_id": "prod_xxx", "success_url": "https://example.com/success"}'

# Response headers 會包含自動產生的 key:
# Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

# 方式二:自行提供 key(適合需要追蹤的情境)
curl -X POST https://api.recur.tw/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Idempotency-Key: order_12345_checkout" \
  -H "Content-Type: application/json" \
  -d '{"product_id": "prod_xxx", "success_url": "https://example.com/success"}'

重複請求的行為:

情境行為
相同 key + 相同參數返回之前的結果(含 Idempotency-Replay: true header)
相同 key + 不同參數返回 400 錯誤(idempotency_key_mismatch
不同 key建立新的資源

目前支援 Idempotency-Key 的 API:

  • POST /v1/subscriptions/:id/complete
  • POST /v1/portal/sessions

Webhook 層:Deterministic Event ID

每個 webhook 事件都有一個 確定性的 Event ID

{
  "id": "evt_a1b2c3d4e5f6...",
  "type": "subscription.activated",
  "data": { ... }
}

這個 ID 是根據以下資訊計算出來的:

eventId = hash(eventType + resourceId + updatedAt)

這代表:

  • 相同的事件 + 相同的資源狀態 → 產生相同的 eventId
  • 即使 webhook 重送多次,eventId 不變
  • 你可以用 eventId 作為去重的依據

事件去重機制

Recur 的 webhook 基礎設施內建去重機制:

  • 1 小時去重窗口:相同 eventId 的事件在 1 小時內只會發送一次
  • 自動重試:失敗的 webhook 會以指數退避策略重試(1, 2, 4, 8, 16 分鐘)

去重機制是「盡力而為」(best-effort) 的額外保護。你仍然應該在自己的系統中實作冪等性檢查。

你的系統如何實現冪等性

核心原則:先寫 DB,再呼叫外部 API

這是實作冪等性最重要的原則:

❌ 錯誤做法:
1. 呼叫外部 API(如發送 email)
2. 寫入資料庫記錄
→ 如果步驟 2 失敗,重試時會重複執行步驟 1

✓ 正確做法:
1. 寫入資料庫記錄(狀態:PENDING)
2. 呼叫外部 API
3. 更新資料庫記錄(狀態:COMPLETED)
→ 重試時,步驟 1 會發現記錄已存在,可以決定是否跳過

Webhook 處理的正確模式

以下是處理 Recur webhook 的推薦模式:

async function handleWebhook(event: WebhookEvent) {
  const { id: eventId, type, data } = event;

  // 1. 檢查是否已處理過(冪等性檢查)
  const existing = await db.webhookEvent.findUnique({
    where: { eventId }
  });

  if (existing) {
    if (existing.status === 'COMPLETED') {
      // 已成功處理,直接返回成功
      console.log(`Event ${eventId} already processed, skipping`);
      return { success: true };
    }
    // 之前處理失敗,可以重試
    console.log(`Event ${eventId} failed before, retrying`);
  }

  // 2. 先寫入 DB(標記為處理中)
  await db.webhookEvent.upsert({
    where: { eventId },
    create: {
      eventId,
      eventType: type,
      payload: data,
      status: 'PROCESSING',
    },
    update: {
      status: 'PROCESSING',
      retryCount: { increment: 1 },
    },
  });

  try {
    // 3. 執行業務邏輯
    await processEvent(type, data);

    // 4. 標記為完成
    await db.webhookEvent.update({
      where: { eventId },
      data: { status: 'COMPLETED' },
    });

    return { success: true };

  } catch (error) {
    // 5. 標記為失敗(下次可重試)
    await db.webhookEvent.update({
      where: { eventId },
      data: {
        status: 'FAILED',
        lastError: error.message,
      },
    });

    throw error; // 返回錯誤讓 webhook 系統知道要重試
  }
}

資料庫設計

確保你的資料表有適當的唯一約束:

CREATE TABLE webhook_events (
  id            SERIAL PRIMARY KEY,
  event_id      VARCHAR(255) UNIQUE NOT NULL,  -- 冪等性關鍵!
  event_type    VARCHAR(100) NOT NULL,
  payload       JSONB,
  status        VARCHAR(20) DEFAULT 'PENDING',
  retry_count   INTEGER DEFAULT 0,
  last_error    TEXT,
  created_at    TIMESTAMP DEFAULT NOW(),
  completed_at  TIMESTAMP
);

-- 確保 event_id 唯一
CREATE UNIQUE INDEX idx_webhook_events_event_id ON webhook_events(event_id);

常見錯誤與反模式

反模式 1:只靠記憶體去重

// ❌ 錯誤:伺服器重啟後記憶體清空
const processedEvents = new Set();

if (processedEvents.has(eventId)) return;
processedEvents.add(eventId);

反模式 2:處理完才記錄

// ❌ 錯誤:如果 recordEvent 失敗,下次會重複處理
await processEvent(event);
await recordEvent(eventId); // 可能失敗!

反模式 3:忽略部分成功

// ❌ 錯誤:如果 sendEmail 成功但 updateDB 失敗,
// 下次重試會再寄一次 email
await sendEmail(user);
await updateDB(userId, { emailSent: true });

狀態機思維

設計冪等系統時,把每個操作想成狀態轉換

每個狀態轉換都應該是:

  1. 原子性的:要嘛完全成功,要嘛完全失敗
  2. 可追蹤的:記錄什麼時候、為什麼轉換
  3. 可重試的:從任何狀態都能安全地重試

檢查清單

在上線前,確認你的系統符合以下條件:

  • Webhook 處理器會檢查 eventId 是否已處理
  • 使用資料庫(而非記憶體)儲存處理狀態
  • eventId 欄位設置 UNIQUE 約束
  • 先寫 DB 記錄,再執行業務邏輯
  • 處理失敗時返回 5xx 錯誤(讓系統知道要重試)
  • 處理成功時返回 2xx(避免不必要的重試)

延伸閱讀

想深入了解冪等性,推薦以下資源:

下一步

On this page