# API 冪等性（Idempotency）與重試設計

Chance Lu

在 API 的世界裡，**重試是常態**：網路會斷、伺服器會忙，遇到失敗「再送一次」幾乎是每個 client 的本能。但只要會重試，就有一個躲不掉的問題——**「同一個請求被執行兩次」怎麼辦？**

💸

想像你在 App 按下「付款」，畫面轉圈圈轉了十秒，然後跳出「連線逾時」。**錢到底扣了沒？**你不知道，App 也不知道。這時候「再送一次」安不安全，就取決於今天的主角：**冪等性（Idempotency）**。

🎯

讀完這篇，你要能用自己的話回答：什麼是冪等性、為什麼「網路不可靠」讓它變成必修、哪些 HTTP 方法天生冪等、以及怎麼用 **Idempotency-Key** 讓「付款」這種 POST 也能放心重試。

## ✎就愛考你

這幾題就是本文的重點，也是讀完你該能**用自己的話**答出來的問題。先想想現在答不答得出來，然後**帶著它們往下讀**——遇到答案所在的段落就特別留意；讀完再回來點「看答案」對照。

1. 用自己的話說：什麼叫「冪等」？「執行多次結果一樣」的『結果』指的是什麼？
      
**同一個請求做一次和做很多次，對伺服器造成的「效果」相同（RFC 9110 §9.2.2）。**
    - 比較的是**伺服器上的狀態**，不是每次的回應內容——DELETE 第二次可能回 404，但「資源已刪除」這個狀態沒變，仍算冪等。
    - 冪等 ≠ 沒有副作用：伺服器照樣可以為每次請求寫 log。
2. Client 送出請求後沒收到回應。為什麼這時「直接重送」對付款 API 是危險的？
      
**因為 client 分不出「請求沒送到」和「處理完了、只是回應在半路斷掉」這兩種情況。**
    - 前者重送沒事；後者重送就是**再扣一次款**。
    - Stripe 把失敗分成三類：連線就失敗／處理到一半斷／處理成功但回應遺失——client 看到的都一樣：沒有回應。
3. POST 和 PUT 都會改伺服器狀態，為什麼一個不冪等、一個冪等？
      
**差在語意：POST 是「新增一筆」，做十次就多十筆；PUT 是「把這個位置整個蓋成這份內容」，蓋十次結果都一樣。**
    - 關鍵不是「會不會寫入」，而是**重複執行會不會累積效果**。
    - 這是 HTTP 規格的「語意約定」——伺服器實作要自己遵守，HTTP 不會自動幫你做到。
4. Idempotency-Key 模式中，伺服器為什麼要存「整份回應（status code + body）」，而不是只記一個「這個 key 做過了」的標記？
      
**因為重送進來時，伺服器要能「把第一次的結果原封不動再回一次」，client 才拿得到它錯過的答案。**
    - 只記「做過了」，重送時你只能回一個空泛的錯誤，client 還是不知道訂單編號、扣款結果。
    - Stripe 的做法：把第一次的 status code + body 存起來，之後同 key 一律回放——**連 500 錯誤也照樣回放**。
5. 同一個 key 的第二發請求進來時，第一發「還在處理中」——伺服器該怎麼辦？
      
**不能再執行一次，也還沒有結果可以回放——所以回一個「衝突」錯誤，請 client 稍後再試。**
    - IETF 草案建議回 **409 Conflict**；Stripe 的做法是不儲存結果、回錯誤讓你重試。
    - 這就是為什麼寫入 key 要用**原子操作**（例如 DB unique constraint 或 Redis `SET NX`）搶「處理權」。
6. 哪些回應該重試、哪些不該？重試的間隔又該怎麼安排？
      
**該重試：連線錯誤／timeout、408、429、5xx；不該重試：其他 4xx（是你的請求有問題，重送一百次也一樣）。**
    - 間隔用 **exponential backoff**（每次失敗等待時間翻倍）＋ **jitter**（加隨機值），避免大家同一秒一起重試把伺服器打死。
    - 這是 Google Cloud 與 Stripe 都採用的標準做法。
7. 綜合題：你要設計一個「建立訂單」API，讓 client 可以安全重試。從 client 到 server 你會做哪些事？
      
**Client 端：產一個 UUID 當 Idempotency-Key，整輪重試共用同一個 key，只對 timeout／408／429／5xx 重試，用 backoff + jitter。**
    - Server 端：用原子寫入搶 key → 沒見過就執行並把 **status + body 跟 key 存在一起**。
    - 同 key 已完成 → 回放存好的結果；同 key 還在跑 → 回 409；同 key 但參數不同 → 回 422。
    - Key 設個存活期（例如 24 小時）再清掉。

## 1冪等性是什麼：做一次和做十次，結果一樣

**一句話：一個操作不管執行一次還是重複執行很多次，對伺服器造成的效果都相同，它就是冪等的。**這是 RFC 9110（HTTP 語意標準）§9.2.2 的定義：「多個相同請求的預期效果，跟單一請求相同」。

兩個生活比喻，感受一下差別：

🛗

    **電梯按鈕＝冪等**
    
按一次是「叫電梯」，狂按十次還是「叫電梯」——電梯不會來十台。

🥤

    **販賣機投幣＝不冪等**
    
投一次扣十元，投十次扣一百元——每做一次，效果就累積一次。

有兩個常見誤解，先拆掉：

🧹

**「結果一樣」指的是伺服器狀態，不是回應內容。**DELETE 同一個資源兩次，第一次回 200、第二次回 404——回應不同，但伺服器狀態相同（東西就是被刪了），所以 DELETE 仍是冪等的（MDN 的範例正是這個）。

🧾

**冪等 ≠ 沒有副作用。**RFC 9110 明講：伺服器照樣可以為每次請求各寫一筆 log、留版本紀錄——冪等只管「使用者要求的那個效果」不重複累積。

💡

這段一句話：冪等問的是「**重複執行，效果會不會疊加**」——會疊加（多扣一次錢、多一筆訂單）就不冪等。

## 2為什麼需要它：網路不可靠，重試是常態

**核心痛點：請求失敗時，client 根本分不出「失敗在哪一步」。**Stripe 在它的工程文章裡把失敗分成三類：

① 連線就沒建起來
    
請求根本沒到伺服器，什麼都沒發生。

    → 重送完全安全 ✓

② 處理到一半斷了
    
伺服器可能做了一半，狀態不明。

    → 狀態不明 ⚠

③ 做完了，回應沒回來
    
伺服器其實處理成功，只是回應在回程斷線——你不知道。

    → 重送＝做第二次 ✗

糟糕的地方在於：**這三種情況，client 看到的長得一模一樣——「沒有回應」。**拿支付當例子（我們每天面對的場景）：

- 不重試 → 如果是情況①，這筆付款就永遠卡住，使用者以為付了、其實沒付。
- 重試 → 如果是情況③，就是**重複扣款**，客訴直接進來。

怎麼都不對。唯一的出路是：**把 API 做成冪等的，然後放心重試到成功為止。**這也是 Stripe 給的結論：冪等之後，client 可以「安全地一直重試，直到拿到明確的成功」。

再往上拉一層：只要系統裡任何一方會重送（client 重試、訊息佇列重投、webhook 重發），接收端看到的世界就是 **at-least-once**——同一個操作「至少送達一次、可能很多次」。想得到「效果只發生一次」（exactly-once 的效果），公式就是：

at-least-once 投遞
  ＋
  接收端冪等
  ＝
  exactly-once 的效果

所以冪等性不是支付 API 的專利——所有會被重試打到的端點（webhook receiver、MQ consumer）都適用同一招。

下面這張動畫把整個故事演一遍：同一筆 $100 付款，回應在半路斷線、App 重送——上半部沒有 Idempotency-Key，下半部有。盯著右邊「銀行帳戶」的差別看：

A｜沒有 Idempotency-Key：重送一次，就多扣一次

  App（client）
  
  支付 API（server）

  銀行帳戶（真的錢）

  ① 付款 $100（POST /pay）

  − $100（第 1 次扣款）

    ② 回應在半路斷線

  ③ 沒收到回應 → 原封重送

  − $100（又扣了一次！）
  
  合計被扣 $200 —— 重複扣款

  B｜有 Idempotency-Key：重送幾次，都只扣一次

  App（client）
  
  支付 API（server）

  伺服器記住的 key：
  abc-123 → 200 OK（連回應一起存）

  銀行帳戶（真的錢）

  ① 付款 $100 ＋ key: abc-123
  − $100（第 1 次扣款）

    ② 回應在半路斷線

  ③ 原封重送（key 不變）

  ④ 認得這個 key → 不重扣，回放存好的結果

  合計被扣 $100 —— 正確 ✓

⬇ 下載 SVG

💡

這段一句話：client 永遠分不出「沒做」還是「做了但我不知道」，所以**安全的重試必須靠伺服器端的冪等來兜底**。

## 3HTTP 方法的天生冪等性

**HTTP 規格已經替每個方法約定好「應不應該冪等」**（RFC 9110 §9.2.2）。整理成一張表：

| 方法 | 安全（唯讀） | 冪等 | 白話理由 |
| --- | --- | --- | --- |
| `GET` / `HEAD` | ✓ | ✓ 冪等 | 只是「看」，看幾次都不會改變什麼 |
| `PUT` | ✗ | ✓ 冪等 | 「把這個位置**整個蓋成**這份內容」——蓋十次結果一樣 |
| `DELETE` | ✗ | ✓ 冪等 | 刪掉的東西再刪一次還是不存在（第二次可能回 404，但狀態沒變） |
| `POST` | ✗ | ✗ 不冪等 | 「新增一筆」——送十次就**多十筆** |
| `PATCH` | ✗ | ✗ 不保證 | 部分修改，像「餘額 −100」這種相對操作重複做就會疊加（PATCH 定義在 RFC 5789，規格沒有要求冪等） |

這個約定有實際後果：RFC 9110 說**冪等方法在連線失敗時，client 可以自動重試**。這就是為什麼瀏覽器重新整理一個 POST 過的頁面會跳「確定要重新提交表單嗎？」——瀏覽器不敢替你自動重送 POST。

兩個提醒：

📜

**冪等是「語意約定」，不是 HTTP 自動施展的魔法。**你把 handler 寫成 PUT 但內部做「累加」，它就不冪等——規格只是要求你「應該」把 PUT 實作成冪等。

💳

而我們最需要冪等的操作——**付款、下單、轉帳——偏偏都是 POST**。天生不冪等的方法要怎麼安全重試？這就是下一節的主角。

💡

這段一句話：GET / PUT / DELETE 天生可重試；**POST 天生不行**——所以需要人工幫它裝上冪等。

## 4Idempotency-Key：讓 POST 也能安全重試

**一句話：client 替「這一次操作」發一張唯一的號碼牌（key），伺服器對同一張號碼牌只真正執行一次，之後都直接回放第一次的結果。**這就是 Stripe 帶紅、現在幾乎是業界標準的 `Idempotency-Key` header 模式。

### 運作流程（三步）

**Client 產 key**
對「這一次業務操作」產一個唯一字串（建議 UUID v4），放進 `Idempotency-Key` header。key 代表「這次付款」，不是「這次 HTTP 請求」——**重試時 key 不能換**。

**Server 第一次看到**
正常執行（扣款、建單），然後把「key → 回應的 status code + body」存起來。

**Server 再看到同一個 key**
不再執行業務邏輯，**直接回放存好的那份回應**。client 拿到的就像第一次沒斷線一樣。

伺服器端的完整判斷流程長這樣：

收到請求，讀出 Idempotency-Key

▼

key 沒看過 → 執行

1. 原子地寫入「處理中」（DB unique constraint / Redis SET NX）
2. 執行業務（扣款）
3. 把 status code + body 跟 key 存在一起
4. 回應 client

key 看過、已有結果 → 比參數

參數跟第一次**一樣** → 直接回放存好的 status + body（不再執行）。
參數**不一樣** → 回 422：同一個 key 不准配不同內容。

key 看過、第一發還在跑 → 擋下

回 409：請稍後再試——不能執行第二次，也還沒有結果可回放。

### 實務細節（踩過坑才知道的部分）

- **Key 的格式與長度**：Stripe 建議 UUID v4 或熵夠高的隨機字串，上限 255 字元；**別拿個資（email、身分證號）當 key**。IETF 草案同樣建議 UUID。
- **Key 有存活期**：Stripe 的 key 存活至少 24 小時，之後清掉；過期後同一個 key 進來會被當成**全新請求**再執行一次。所以存活期要蓋過你的最長重試窗口。
- **同 key 不同參數 → 直接報錯**：Stripe 會比對重送的參數跟原請求是否一致，不一致就回錯誤，防止「拿舊 key 送新內容」的誤用。IETF 草案建議回 **422 Unprocessable Content**。
- **併發重送 → 回衝突**：第一發還在處理中、同 key 第二發就進來了——Stripe 不會儲存結果、會回錯誤讓你稍後重試；IETF 草案建議回 **409 Conflict**。這也是為什麼「寫入 key」必須是原子操作，兩發同時搶只能有一發搶到執行權。
- **失敗的結果也會被回放**：Stripe 連第一次拿到的 **500 錯誤都照存照回放**。另一個細節：Stripe 只在「endpoint 真的開始執行」之後才存結果——參數驗證就失敗的請求不會佔用 key，可以直接重試。
- **只有 POST 需要**：Stripe 明講不要在 GET / DELETE 上送 key——它們本來就冪等，送了也沒作用。

**標準化進度（誠實說明）**：`Idempotency-Key` header 有一份 IETF 草案（draft-ietf-httpapi-idempotency-key-header，2025-10 出到第 07 版），但**目前仍是過期的 draft、還不是正式 RFC**。實務上大家照著 Stripe 的模式做，行為大同小異，但細節（狀態碼、過期時間）各家可能不同，接第三方 API 時要看它自己的文件。

### 實際長什麼樣：Stripe 的例子

對 Stripe 建立一筆客戶資料，帶上 key。這條指令重跑幾次，都只會建立一個 customer：

```
curl https://api.stripe.com/v1/customers \
  -u sk_test_xxx: \
  -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
  -d description="第一筆測試客戶"
```

💡

這段一句話：Idempotency-Key ＝「client 發號碼牌、server 記帳本」——**同一張牌只做一次事，之後都回放第一次的答案**。

## 5重試設計：該重試什麼、怎麼重

有了冪等性兜底，重試才敢做。但重試本身也有章法——**亂重試比不重試更危險**（把已經在掙扎的伺服器打得更死）。三個原則：

### 原則一：只重試「暫時性」的失敗

判斷標準很簡單：**只有「等一下可能就會好」的失敗，重試才有意義**。配上 Google Cloud 的重試清單，整理成一張表：

| 拿到什麼 | 重試？ | 理由 |
| --- | --- | --- |
| 連線錯誤 / timeout（沒有回應） | ✓ 要 | 典型的暫時性故障；也是最需要 Idempotency-Key 的情況——你不知道對面做了沒 |
| `408`（請求逾時）、`429`（被限流） | ✓ 等一下再試 | 429 記得尊重 `Retry-After` header |
| `5xx`（500 / 502 / 503 / 504） | ✓ 要 | 伺服器那邊的問題，等一下可能就好了 |
| 其他 `4xx`（400 / 401 / 403 / 404 / 422…） | ✗ 不要 | 是你的請求有問題——同一份東西重送一百次，答案還是一樣 |

### 原則二：exponential backoff + jitter

- **Exponential backoff（指數退避）**：每失敗一次，等待時間翻倍（0.5s → 1s → 2s → 4s…），別對故障中的伺服器連環轟炸。
- **Jitter（抖動）**：在等待時間上加一點隨機值。不然一次故障後，成千上萬個 client 會在**同一秒**一起醒來重試，把伺服器再打趴一次（Stripe 稱之為 thundering herd，驚群效應）。

### 原則三：整輪重試共用同一個 key

把三個原則拼起來，client 端的完整寫法（可直接拿去改）：

```
async function payWithRetry(order) {
  const key = crypto.randomUUID();   // ★ 只產一次：整輪重試共用同一個 key
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const res = await fetch("https://api.example.com/v1/charges", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": key,    // ★ 重試時 key 不變，server 才認得出是同一筆
        },
        body: JSON.stringify(order),
      });
      if (res.ok) return await res.json();               // 成功，收工
      if (res.status < 500 && res.status !== 408 && res.status !== 429) {
        throw new FatalError(await res.text());          // 其他 4xx：重試沒意義
      }
      // 408 / 429 / 5xx：掉下去走重試
    } catch (e) {
      if (e instanceof FatalError) throw e;              // 連線錯誤 / timeout：走重試
    }
    const wait = 500 * 2 ** attempt + Math.random() * 400;  // backoff + jitter
    await new Promise(r => setTimeout(r, wait));
  }
  throw new Error("重試多次仍失敗：標記待確認，交給對帳/排程處理");
}
```

💡

這段一句話：重試的紀律＝**只重試暫時性錯誤、越退越久加隨機、key 從頭用到尾**。

## 6容易踩的雷

⚠️

**重試時換了新 key。**最常見也最致命——server 每次都當新請求，等於完全沒做冪等。key 綁「這次業務操作」，不綁「這次 HTTP 請求」。

⚠️

**只記「key 用過了」，沒存回應。**重送進來你回不出第一次的結果，client 還是不知道訂單長怎樣，等於沒解決問題。status code + body 要一起存。

⚠️

**寫 key 不是原子操作。**兩發併發重送同時查到「key 不存在」→ 兩發都執行 → 照樣扣兩次。要用 DB unique constraint 或 Redis `SET NX` 這類原子寫入搶執行權。

⚠️

**key 存活期短於重試窗口。**key 被清掉之後的重送會被當新請求再執行一次。存活期要蓋過「client 最久可能隔多久重試」（Stripe 用 24 小時當底線）。

⚠️

**「存結果」和「做業務」不在同一個交易裡。**扣款成功但存 key 前程序掛掉 → 重送時 key 查不到 → 再扣一次。理想上兩者要原子地一起完成；這塊要做到滴水不漏還有不少細節（例如跨外部服務時），可以延伸讀 Brandur 的 *Implementing Stripe-like Idempotency Keys in Postgres*。

⚠️

**對不冪等的操作無腦自動重試。**Google Cloud 明確把「未加條件的重試不冪等操作」列為反模式——先確認冪等，再開自動重試。

## ★一句話帶走

**網路一定會斷，所以重試一定會發生；重試一定會發生，所以每個會改狀態的端點都要能「被重複打而不重複生效」。**GET / PUT / DELETE 靠語意天生冪等，POST 靠 Idempotency-Key 後天補上——client 發同一張號碼牌重試到底，server 只做一次、回放到底。

## 📚參考來源

- [RFC 9110: HTTP Semantics — §9.2 Safe / Idempotent Methods](https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods)（IETF 標準：冪等的正式定義、各方法屬性、自動重試）
- [Stripe API Reference — Idempotent requests](https://docs.stripe.com/api/idempotent_requests)（Idempotency-Key 的官方規則：UUID、255 字元、24 小時、參數比對、連 500 都回放）
- [Stripe Engineering — Designing robust and predictable APIs with idempotency](https://stripe.com/blog/idempotency)（網路三種失敗、exponential backoff、jitter、thundering herd）
- [IETF draft-ietf-httpapi-idempotency-key-header-07](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)（Idempotency-Key 標準化草案：409 / 422 / 400 的建議；**注意：仍是 expired draft，非正式 RFC**）
- [Google Cloud Storage — Retry strategy](https://docs.cloud.google.com/storage/docs/retry-strategy)（哪些錯誤可重試：408 / 429 / 5xx；exponential backoff with jitter；冪等前提）
- [MDN Web Docs — Idempotent](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent)（DELETE 回應不同但狀態相同的範例、方法分類）
- [brandur.org — Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys)（延伸閱讀：server 端交易一致性的實作細節；本文未展開）
