主題: Web platform

重試不是再做一次:API 冪等 key 的真正邊界

逾時不代表失敗。用操作 key、參數綁定、原子紀錄、有限重試與 outbox,避免付款、預約與背景工作被重複執行。

按下「付款」後畫面轉了很久,最後只顯示逾時。這時最危險的直覺,是把沒有收到回應當成伺服器沒有執行,再送一次新的付款請求。

動態迷因(展開/收合)
要放心按下重試,靠的不是運氣,而是事先寫清楚的冪等契約。 · 來源:GIPHY

逾時只告訴我們「結果未知」:請求可能根本沒送到,也可能已完成,只是回應在途中遺失。API 冪等設計要處理的,就是這段不知道能不能重做的空白。

冪等不是每次都回傳一模一樣

MDN 對 idempotent 的定義著重在預期效果:送出一次或多次相同請求,伺服器的目標狀態應相同。回應不必完全相同。例如第一次 DELETE 可能回 200,第二次回 404;資源仍然都是不存在。

HTTP method 的語意提供一個起點:

Method 規格層級的基本語意 常見重試判斷
GET safe 且 idempotent 通常可重試,但仍要限制次數與流量
PUTDELETE idempotent 相同請求的目標效果應一致
POSTPATCH 不保證 idempotent 必須由 API 另行定義契約

這不表示每個 GET 實作都沒有副作用,也不表示所有 PUT 都寫得正確。規格描述的是 method 應有的語意;實作仍可能違反它。對建立付款、預約座位或排入工作佇列的 POST,更不能只靠 method 名稱推定安全。

Key 表達的是同一個意圖

假設使用者要建立一筆付款,client 應在第一次送出前建立操作 key,並保存到結果確定為止:

const operationKey = crypto.randomUUID();

async function submitPayment(payload: PaymentInput) {
  return fetch("/api/payments", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "idempotency-key": operationKey,
    },
    body: JSON.stringify(payload),
  });
}

第一次逾時後,重試同一個操作要沿用同一個 key。使用者稍後明確發起另一筆付款,才建立新 key。若等到逾時後才補一個 key,伺服器無法把第二次請求和已經抵達的第一次連起來。

Key 也不能只拿來找舊結果,卻不檢查內容。伺服器應把 key 與正規化後的操作參數綁在一起:

  • 相同 key、相同參數:回放既有結果,或告知仍在處理。
  • 相同 key、不同參數:拒絕為 conflict;不能偷偷建立另一筆操作。
  • 不同 key:視為新的使用者意圖,即使 payload 剛好相同。

用 payload hash 自動推導意圖並不可靠。兩張內容相同的訂單可能是使用者真的要買兩次;反過來,同一操作也可能因 JSON 欄位順序不同而得到不同 hash。AWS 在讓重試安全的設計文章中因此主張由 caller 提供 request identifier,讓意圖變得明確。

伺服器要原子地認領 key

只在單一程式 instance 的記憶體放一個 Set 不夠。多個 instance、重新啟動或並發請求都可能繞過它。最小的持久化紀錄可以長這樣:

CREATE TABLE idempotency_operations (
  tenant_id text NOT NULL,
  operation text NOT NULL,
  operation_key uuid NOT NULL,
  request_hash text NOT NULL,
  status text NOT NULL CHECK (status IN ('processing', 'completed')),
  response_status integer,
  response_body jsonb,
  expires_at timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, operation, operation_key)
);

重點不是表名,而是唯一約束與業務寫入必須處在正確的原子邊界。兩個相同請求同時抵達時,只能有一個成功認領 key;另一個不能也執行一次副作用。完成後保留足以重建 API 回應的狀態,後續相同請求才能得到語意相同的結果。

tenant_idoperation 也不能省略。不同租戶或不同 endpoint 不應只因碰巧使用相同 UUID 就互相看到結果。冪等 key 更不是授權:伺服器仍須先驗證身分與操作權限,而且不能把其他使用者的回應洩漏出去。

哪些失敗可以重試,要看執行是否開始

Stripe 的 idempotent request 契約提供一個具體例子。Stripe 在 endpoint 開始執行後保存第一次產生的 status code 與 body,連 500 回應也會保存並回放。這是 Stripe 明確承諾的 API 行為,不是所有 POST 自動擁有的 HTTP 規則。

另一個容易漏掉的邊界是:若參數驗證失敗,或請求在真正開始執行前因並發衝突被拒絕,Stripe 不保存 idempotent result,修正或稍後重試仍有可能執行。看到同一個 key,不應直接推定伺服器已做過業務變更;必須依該 API 對「何時開始執行」的定義判斷。

Key 也需要 retention policy。Stripe 文件說明可在至少 24 小時後移除舊 key;其他系統應依自己的最長重試窗口、法規與儲存成本定義期限。若過期 key 被當成新請求接受,client 就不能無限期重送舊操作。

有冪等 key,仍要限制重試

冪等降低重複副作用的風險,並沒有讓無限重試變免費。AWS 的 timeout、backoff 與 jitter 指南提醒:每一層都重試,流量會相乘;大量 client 同時依固定間隔重送,也可能讓正在恢復的服務再次塞住。

實務上可以把政策寫得保守一些:

  • 網路中斷、逾時、部分 5xx:在單一層做有限次 exponential backoff,加上 jitter。
  • 429 Too Many Requests:遵守服務提供的等待提示,例如 Retry-After
  • 400 類驗證錯誤:先修正輸入,不要原封不動重送。
  • 結果未知但有查詢 endpoint:先用同一個操作識別碼查狀態,再決定是否重試。
動態迷因(展開/收合)
沒有穩定 key、原子紀錄與重試上限,再試一次很可能只是再製造一次失敗。 · 來源:GIPHY

Outbox 解決本機承諾,不保證外部只做一次

資料庫 transaction 可以一起寫入訂單與待發事件:

BEGIN;

INSERT INTO orders (id, customer_id, total)
VALUES ($1, $2, $3);

INSERT INTO outbox (event_id, topic, payload)
VALUES ($4, 'order.created', $5);

COMMIT;

這能避免「訂單成功,但程式在送出事件前 crash」造成永久漏送。AWS transactional outbox pattern也強調,relay 仍可能把同一事件送出多次。因此 consumer 需要穩定的 event_id 或下游 idempotency key,並以可重跑、可對帳的方式處理。Outbox 建立的是本機資料與待送意圖的原子性,不是跨資料庫、queue 與第三方 API 的 exactly-once 保證。

先保護會造成真實副作用的操作

若時間有限,不必先替所有 endpoint 建造龐大框架。優先順序可以依重複執行的代價決定:

優先度 操作 重複執行的可能代價
付款、退款、建立訂單、預約、扣庫存 重複扣款、超賣、重複承諾
建立背景工作、接收 webhook 工作或通知執行多次
可覆寫的設定更新 最後狀態可能相同,但仍需處理競爭
靜態 GET、純計算轉換 通常沒有持久副作用,重點在成本與流量

完成機制後,也要觀察它是否真的工作。至少追蹤相同 key 回放次數、payload conflict、同 key 並發、過期 key 重用、處理中逾時、下游重複與最終失敗。這些訊號能分辨「使用者正常重試」和「client 失控重送」,也能找出只在單一路徑去重的缺口。

一份可操作的檢查表

  1. Client 是否在第一次送出前建立 key,並讓同一操作的所有重試沿用它?
  2. 新操作是否一定使用新 key,而不是依 payload 猜測意圖?
  3. 同 key 換 payload 時,API 是否明確拒絕?
  4. Key 的 scope、保存期限與過期行為是否有文件?
  5. Key 認領、業務寫入與結果保存是否能承受並發與程式重啟?
  6. 身分驗證與授權是否獨立執行?
  7. 哪些錯誤可重試、在哪一層重試、最多幾次,是否寫成契約?
  8. Outbox relay 與 downstream consumer 是否也能處理重複 delivery?
  9. 能否用操作 ID 查詢狀態並完成對帳?

我學到什麼

  • 我不再把 client 逾時當成伺服器回滾;結果未知時,應用同一個操作識別碼查詢或重試。
  • 我會把 POST 的冪等視為 API 明確提供的契約,而不是 HTTP 自動保證的能力。
  • 我會在第一次送出前建立 key,讓相同 key 綁定相同 payload,並把新的使用者意圖換成新 key。
  • 我會把 outbox 當成本機原子承諾;下游仍須能處理重複 delivery,授權也仍是另一道檢查。

外部參考資料