冪等鍵的標準化現況是業界實作先行、正式標準停滯:IETF httpapi 工作組的 Idempotency-Key header draft 推進到版本 07 後過期,狀態為 expired(見 11.C40)。draft 期滿是 IETF 的流程事實,本文不推測它為什麼沒有續推 —— 工作組人力、優先序、爭議未收斂都可能是原因,而公開紀錄沒有給答案。本文分析的是另一件事:即使 draft 當年推進到 RFC,統一得了的是什麼、統一不了的又是什麼。切線不在「命名 vs 語意」——那條線畫得太粗,因為有些語意其實是普世的。切線在形狀 vs 值:一份標準規定得了 header 叫什麼名字、重放要用哪個欄位標示、同 key 不同參數必須報錯、保存期必須以某個明文欄位揭露;規定不了保存期是 24 小時還是 7 天、replay 該回首次快照還是最新狀態,因為這些取決於這個 API 在做什麼。HTTP 對快取正是這樣處理的:Cache-Control 標準化了一份完全應用相關的政策該怎麼揭露,沒有規定任何一個值。

這件事對讀者的實際意義是:在標準缺席的現在,那份「形狀」得由每個整合方自己手工維護一次,而本文其餘各節就是那份手工版本。本文攤開各家條款的實質差異與它們各自的成立情境。自建一套冪等機制要承諾哪些條款,見 API 層冪等設計

先問這個操作需不需要 key

冪等鍵是一套要兩端協作的機制,而協作有前提:消費者改得動自己的 client、而且重試窗口短於服務端的保存期。這兩件事不成立時,再完美的條款也沒有人送 key,機制形同不存在。先過這道閘門,再談條款。

前提不成立的三種常見形態,各有不需要 key 的解。消費者改不動——長尾整合方不會為了一個新 header 改 client,而重複建立的客訴正是他們造成的;此時把去重移到服務端,用請求裡本來就有的欄位組成 natural key(同一個租戶、同一個時段、同一個對象),加一條唯一約束擋在資料庫層。操作本身是 set 語意——授予權限、設定標籤、更新狀態這類寫入,重送兩次的結果跟一次相同,天然冪等,加 key 是純成本。重試窗口遠長於任何保存期——離線緩衝數日的裝置回報,任何保存期都撐不住,正解同樣是 natural key 加唯一約束,因為 natural key 沒有保存期。

這三條路換掉的東西要講清楚:消費者失去了自己定義「同一次操作」的自由。同一份請求內容送兩次,在 key 機制下由消費者決定算不算重複(他給不給同一個 key),在 natural key 下由服務端的欄位選擇決定。使用者真的想連下兩筆一模一樣的訂單時,前者做得到、後者做不到,而這是產品決定不是技術決定——natural key 的欄位裡因此常要加一個由消費者提供的區分值,而那個值一旦存在,它就是一個 key,只是換了名字住在 body 裡。

閘門過得了的情境——消費者改得動、重試窗口以分鐘計、且「同一次操作」的邊界必須由消費者定義——才進入本文其餘各節的條款比較。

命名之爭與語意之爭要分開看

觀察到的分歧有兩層。表層是 header 命名:Stripe 用 Idempotency-Key、PayPal 用 PayPal-Request-Id,且並非所有 PayPal API 都支援這個 header(見 11.C41)。這是無標準的直接後果,而「同一家內還要逐 API 查」正是它的極端形態 —— 整合方每接一家就要查一次 header 名,SDK、gateway 與觀測層都無法對這個機制寫通用邏輯。

深層是條款語意,而它跟業務形態綁在一起(以下對標準化難度的分析是從各家條款差異推導,未見任何公開規範明文處理)。難以統一的理由有三個:「同一次操作」的邊界由消費者定義,只有消費者知道兩次呼叫算不算同一件事;同 key 帶了不同參數算不算衝突,取決於這個 API 的哪些參數屬於操作語意、哪些只是傳輸細節;replay 該回什麼,取決於這個操作在首次回應時是否已經結束。三個理由都指回各 API 自己的業務形態,統一的 header 名解不了任何一個。

這個分層決定了標準化能拿回什麼:即使語意永遠各家不同,統一命名仍讓整條鏈上的通用元件可以識別、記錄與轉發這個 header。draft 停滯的代價落在這一層,而非落在語意層。

各家實際會不一樣的條款有六項,本文其餘各節逐項展開,整合任何一家時它們就是檢查表:header 名replay 回什麼replay 認不認得出來保存期同 key 並發怎麼處理同 key 不同參數怎麼處理

第三項最容易被漏掉,而它的槓桿最大:服務端有沒有明說「這一次是重放」(回應標記或一個 header)。有這個標記時,消費者同時吃得下快照派與現況派——他知道手上這份回應是重放還是新結果,兩派之爭在整合端的壓力大半消失;沒有標記時,消費者只能靠猜。

第六項在多數情境是條款、在一種情境是安全問題:key 的命名空間若不綁帳號或不綁 endpoint,同一個轉售商底下的兩個終端客戶就共用一組 key 空間,撞號時一方拿到另一方的操作結果。

這六項是11.8 的冪等契約條款清單在跨家比較下的切面 —— 該章寫的是自建時要承諾什麼(包含 key 由誰生成、只作用於 POST 這類各家沒有分歧的項目),本文只收各家答案會不同的那些。

Replay 語意的快照派與現況派各有成立情境

最實質的分歧在同 key 重送時回什麼。Stripe 回傳首次請求的 status code 加 body,包含 500 也照樣快取重放(見 11.C39);PayPal 回傳前次請求的最新狀態(C41)。案例判讀已點出取捨方向:後者對非同步操作友善,而失去 exactly-once 的回應保證。

兩派的差異在同步操作上幾乎觀察不到 —— 操作在首次回應時已經結束,首次結局跟最新狀態是同一份資料。分歧要在操作跨越首次回應的時間軸時才浮現,而兩個方向的後果相反(以下從兩家的條款推導)。

首次回應是 202、背景處理稍後完成時,快照派會持續回那個 202,消費者拿到的永遠是當初那份「已接受」的回應,要知道最終結果得走另一條查詢路徑;現況派直接回最新狀態,消費者用同一個 key 就能追蹤到終局。這一種操作形態是現況派的主場 —— C41 判讀點出的「對非同步操作友善」正落在這裡。

首次回應是 500、而服務端的背景重試後來成功時,方向反了過來,而兩派各自付的代價都不是零。快照派持續回 500,判讀依據穩定 —— 代價是它穩定地錯:操作其實已經成功,而消費者據此走人工核對或補償流程,補償一筆已成功的操作是這條路的實際風險。現況派回成功,消費者拿到的是真實狀態 —— 代價是同一個 key 在不同時點重送會拿到不同答案,重試邏輯要能承受這件事,而它多半也需要一個欄位來區分「這是重放」與「這是新請求的結果」。

兩派的比較軸因此是可預測但可能過期,對上反映真實但不可預測。判準有兩軸。操作形態是第一軸:操作在回應時已結束的 API 選快照派,兩個代價都不會出現;操作本質上非同步、且沒有另建查詢入口的 API,現況派讓 key 兼任追蹤入口。回應要不要可重現是獨立的第二軸,而它會壓過第一軸:受稽核、要處理帳務爭議或做對帳的 API,必須能重現「時點 T 回給消費者的到底是什麼」,這條理由跟同步或非同步無關 —— 一個非同步的金融 API 仍然該選快照派,非同步的需求改由查詢資源承接。

二選一之外實務上還有三條混合路線,各自解掉快照派的一個代價。快照配查詢資源:回應保持可預測,終局狀態走它自己的介面(該模式見 11.7 的長時操作段)。重送回 409 加 Location:不重放內容,直接把消費者指向結果資源,讓終局由資源本身承擔,建立型 API 常走這條。快照加新鮮度標記:回首次結局的同時附上「這是重放、而目前狀態已變動」的指標,一次給兩層——這條其實就是前一節第三項條款(replay 認不認得出來)落地之後的樣子,也是資訊最完整的一種。

保存期的精確度決定消費者能設計多長的重試窗口

Stripe 承諾保存至少 24 小時、逾期後同 key 視為新請求(C39);PayPal 的保存期寫「a period of time」,細節要查各 API reference(C41)。這組對照是契約精確度的差異,而它對消費者的影響是可以直接推導出來的。

保存期明文時,消費者能設計出對應的重試窗口:在窗口內重送安全,超過窗口就改走查詢或人工核對。保存期模糊時,安全的消費者只能假設它很短,於是超過幾分鐘的重試都得先查詢再決定 —— 冪等鍵在長時間重試上的價值,正是被這份模糊吃掉的。

這一條對自建 API 的意義是它幾乎沒有成本:保存期是實作上已經決定好的值,把它寫進文件只差一句話,而不寫的話消費者拿不到任何可依賴的承諾。

整合方這一端則有一條退路,而它是六項裡唯一量得出來的:保存期。在沙箱環境用同一個 key 以遞增間隔重送,觀察從哪一次開始被當成新請求,量出來的窗口比文件上的「a period of time」可用得多。量到的值要當觀察而非承諾 —— 它隨時可能改,而沒有明文的值改了不會有人通知。

同 key 並發是重試風暴下的常態

PayPal 明示同 ID 並發請求時第二個可能失敗(C41)。這個條款容易被當成邊緣情況,而它在故障當下是主流形態 —— 消費者超時後重送時,首次請求往往還在服務端執行,兩個帶同一個 key 的請求因此同時在跑。

服務端的兩種回法各有代價(此處為從機制推導)。拒絕第二個請求把控制權交回消費者,消費者拿到一個明確錯誤、退避後再試,代價是消費者的重試邏輯要能區分「並發衝突」與「參數衝突」這兩種同 key 錯誤,而它們的正確反應相反:前者該重試,後者該停止。阻塞第二個請求直到首次完成,讓消費者拿到跟正常呼叫一樣的回應,代價是服務端要維護等待狀態,且首次請求掛住時第二個也跟著掛住。

自建時的最低要求是把選擇寫進文件,並讓兩種同 key 錯誤在錯誤模型裡可區分。Stripe 為冪等衝突保留了一級錯誤型別 idempotency_error(見 11.4 錯誤模型設計),而區分並發與參數衝突需要的是型別之下的細分碼。

借用結論而不帶前提

拿 Stripe 的語意假設去打別家。Stripe 的條款是目前公開文件裡最明確的一份,因此常被當成冪等鍵的預設語意。整合另一家時,前述六項條款逐一會不同,而錯誤的形態是靜默的:重試拿到的回應跟預期不同,程式繼續往下走。檢查問法:六項在整合文件裡都查得到嗎。文件的答案不可操作時等同查不到:保存期沒有具體時長、replay 沒有指名回首次快照或最新狀態,這類文字看起來像答案而不能拿來設計。逐項套保守預設 —— header 名假設每個 API 都要各查一次、replay 假設回最新狀態(因此不把回應當終局)、replay 假設無標記(因此自己記下哪些 key 已送出)、保存期假設「請求結束後就不保證 key 還在」(不要自己猜一個具體時長,猜短了會白白放棄冪等鍵在長時間重試上的價值)、並發假設第二個請求會失敗(因此重試要帶退避)、同 key 不同參數假設服務端不檢查(因此自己保證同一個 key 只配一份參數)。

引 draft 當標準。draft 狀態是 expired,引用它只能引語意骨架(C40)。把它寫成「符合 RFC」的文件會讓消費者以為有一份權威規範可查,而實際能查的只有各家自己的條款。檢查問法:自家文件裡提到這個 header 的那一句,有沒有出現 RFC 或標準字樣而後面接不出編號。

只快取成功結果。這是自建最容易做錯的一條:快取的對象是該次請求的結局,而非成功結果。只快取成功時,server 錯誤後的同 key 重試會觸發第二次執行 —— 冪等保證在最需要它的時刻失效(C39 判讀)。檢查問法:讓某個帶 key 的請求回 500,立刻用同一個 key 重送,服務端執行了第二次嗎。

假設消費者不會誤用 key。同 key 不同參數直接報錯這條的作用是防止 key 被當 session id 濫用(C39)。它在六項條款裡的位置特殊 —— 其餘五項的答案隨業務形態變動、各家不同是正常的,而這一條在任何業務形態下都成立,因此可以給通則。理由是它保護的對象是 key 的定義本身、而非任何業務結果:key 代表「消費者眼中的同一次操作」,同一個 key 配上兩份不同的參數,這個定義當場失效——與這個 API 在做什麼無關,所以它是前面說的「形狀」而非「值」。缺了它,消費者無意間重用 key 時拿到的是前一次操作的結果,而它看起來像成功。檢查問法:用同一個 key 送兩個金額不同的請求,第二個拿到的是錯誤還是第一筆的結果。

自建與整合共用同一份條款

無標準的現況把兩端推向對稱:同一份六項條款,自建時是要寫進文件的承諾,整合時是要逐家讀的檢查表。自建方寫明條款,消費者才設計得出重試;整合方讀不到條款,就在自己這端補上對應的保守處理。這份對稱是雙方在沒有共同規範時可依賴的東西 —— 它不需要任何一方等 IETF,只需要兩邊都認得同一組問題。

下一步路由