# 冪等性 (/api/idempotency)





冪等性 (Idempotency) [#冪等性-idempotency]

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

什麼是冪等性？ [#什麼是冪等性]

**冪等性**（Idempotency）是一個數學與電腦科學的概念：

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

生活中的冪等操作 [#生活中的冪等操作]

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

非冪等操作 [#非冪等操作]

* **轉帳**：執行兩次 = 轉兩次錢（災難！）
* **發送訊息**：執行兩次 = 收到兩則相同訊息
* **計數器 +1**：執行兩次 = 加了 2

<Callout type="info">
  在 API 設計中，`GET`、`PUT`、`DELETE` 通常是冪等的，而 `POST` 通常不是。
</Callout>

為什麼支付系統需要冪等性？ [#為什麼支付系統需要冪等性]

網路的不可靠性 [#網路的不可靠性]

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

<Mermaid
  chart="`sequenceDiagram
  participant U as 用戶
  participant N1 as 網路
  participant S as 伺服器
  participant N2 as 網路

  U->>N1: 發送請求
  Note over N1: ❌ 可能失敗（超時、斷線）
  N1->>S: 請求送達
  S->>S: 處理請求
  S->>N2: 發送回應
  Note over N2: ❌ 可能失敗（回應遺失）
  N2->>U: 回應送達
`"
/>

常見的失敗情境：

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

災難性的後果 [#災難性的後果]

當用戶看到「請稍後再試」，他們會怎麼做？&#x2A;*再試一次。**

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

**案例一：重複扣款**

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

**案例二：儲值系統重複入帳**

儲值型系統（如點數、遊戲幣、錢包餘額）面臨相反的風險：

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

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

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

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

真實世界的重試來源 [#真實世界的重試來源]

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

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

Recur 的冪等性設計 [#recur-的冪等性設計]

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

API 層：Idempotency-Key Header [#api-層idempotency-key-header]

對於建立資源的 API（如建立結帳工作階段），Recur 採用類似 Stripe API v2 的設計：

* **Idempotency-Key 是可選的**：如果你不傳，系統會自動產生 UUID
* **所有 POST 請求預設冪等**：無論是否傳入 key，相同請求不會重複建立資源
* **24 小時有效期**：相同 key 在 24 小時內會返回相同的結果

```bash
# 方式一：讓系統自動產生 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        | 建立新的資源                                       |

<Callout type="info">
  目前支援 Idempotency-Key 的 API：

  * `POST /v1/subscriptions/:id/complete`
  * `POST /v1/portal/sessions`
</Callout>

Webhook 層：Deterministic Event ID [#webhook-層deterministic-event-id]

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

```json
{
  "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 分鐘）

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

你的系統如何實現冪等性 [#你的系統如何實現冪等性]

核心原則：先寫 DB，再呼叫外部 API [#核心原則先寫-db再呼叫外部-api]

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

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

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

Webhook 處理的正確模式 [#webhook-處理的正確模式]

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

```typescript
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 系統知道要重試
  }
}
```

資料庫設計 [#資料庫設計]

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

```sql
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);
```

常見錯誤與反模式 [#常見錯誤與反模式]

<Callout type="error" title="反模式 1：只靠記憶體去重">
  ```typescript
  // ❌ 錯誤：伺服器重啟後記憶體清空
  const processedEvents = new Set();

  if (processedEvents.has(eventId)) return;
  processedEvents.add(eventId);
  ```
</Callout>

<Callout type="error" title="反模式 2：處理完才記錄">
  ```typescript
  // ❌ 錯誤：如果 recordEvent 失敗，下次會重複處理
  await processEvent(event);
  await recordEvent(eventId); // 可能失敗！
  ```
</Callout>

<Callout type="error" title="反模式 3：忽略部分成功">
  ```typescript
  // ❌ 錯誤：如果 sendEmail 成功但 updateDB 失敗，
  // 下次重試會再寄一次 email
  await sendEmail(user);
  await updateDB(userId, { emailSent: true });
  ```
</Callout>

狀態機思維 [#狀態機思維]

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

<Mermaid
  chart="`flowchart TD
  START((&#x22;收到請求&#x22;)) --> CHECK{已處理過?}
  CHECK -->|是 - COMPLETED| RETURN[直接返回成功]
  CHECK -->|是 - FAILED| RETRY[允許重試]
  CHECK -->|否| PENDING[PENDING]

  PENDING --> PROCESSING[PROCESSING]
  RETRY --> PROCESSING

  PROCESSING -->|成功| COMPLETED[COMPLETED]
  PROCESSING -->|失敗| FAILED[FAILED]

  COMPLETED --> END((&#x22;結束&#x22;))

  style RETURN fill:#d4edda,stroke:#28a745
  style COMPLETED fill:#d4edda,stroke:#28a745
  style FAILED fill:#f8d7da,stroke:#dc3545
`"
/>

每個狀態轉換都應該是：

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

檢查清單 [#檢查清單]

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

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

延伸閱讀 [#延伸閱讀]

想深入了解冪等性，推薦以下資源：

* [Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys) - Brandur Leach 的經典文章
* [Stripe API: Idempotent Requests](https://stripe.com/api/idempotent_requests) - Stripe 的官方文件

下一步 [#下一步]

* [Webhook 事件類型](/guides/webhooks/events) - 了解所有可用的 webhook 事件
* [Webhook 處理範例](/guides/examples/webhook-handling) - 完整的程式碼範例
* [錯誤處理](/guides/checkout/error-handling) - 處理各種錯誤情境
