主題: Web platform
重試不是再做一次:API 冪等 key 的真正邊界
逾時不代表失敗。用操作 key、參數綁定、原子紀錄、有限重試與 outbox,避免付款、預約與背景工作被重複執行。
按下「付款」後畫面轉了很久,最後只顯示逾時。這時最危險的直覺,是把沒有收到回應當成伺服器沒有執行,再送一次新的付款請求。
動態迷因(展開/收合)
逾時只告訴我們「結果未知」:請求可能根本沒送到,也可能已完成,只是回應在途中遺失。API 冪等設計要處理的,就是這段不知道能不能重做的空白。
冪等不是每次都回傳一模一樣
MDN 對 idempotent 的定義著重在預期效果:送出一次或多次相同請求,伺服器的目標狀態應相同。回應不必完全相同。例如第一次 DELETE 可能回 200,第二次回 404;資源仍然都是不存在。
HTTP method 的語意提供一個起點:
| Method | 規格層級的基本語意 | 常見重試判斷 |
|---|---|---|
GET |
safe 且 idempotent | 通常可重試,但仍要限制次數與流量 |
PUT、DELETE |
idempotent | 相同請求的目標效果應一致 |
POST、PATCH |
不保證 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_id 與 operation 也不能省略。不同租戶或不同 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:先用同一個操作識別碼查狀態,再決定是否重試。
動態迷因(展開/收合)
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 失控重送」,也能找出只在單一路徑去重的缺口。
一份可操作的檢查表
- Client 是否在第一次送出前建立 key,並讓同一操作的所有重試沿用它?
- 新操作是否一定使用新 key,而不是依 payload 猜測意圖?
- 同 key 換 payload 時,API 是否明確拒絕?
- Key 的 scope、保存期限與過期行為是否有文件?
- Key 認領、業務寫入與結果保存是否能承受並發與程式重啟?
- 身分驗證與授權是否獨立執行?
- 哪些錯誤可重試、在哪一層重試、最多幾次,是否寫成契約?
- Outbox relay 與 downstream consumer 是否也能處理重複 delivery?
- 能否用操作 ID 查詢狀態並完成對帳?
我學到什麼
- 我不再把 client 逾時當成伺服器回滾;結果未知時,應用同一個操作識別碼查詢或重試。
- 我會把
POST的冪等視為 API 明確提供的契約,而不是 HTTP 自動保證的能力。 - 我會在第一次送出前建立 key,讓相同 key 綁定相同 payload,並把新的使用者意圖換成新 key。
- 我會把 outbox 當成本機原子承諾;下游仍須能處理重複 delivery,授權也仍是另一道檢查。