iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰系列 第 5

Day 5 | 雙鍵設計 (上):idpk 如何守住 API 層的重試攻擊

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-05

昨天的狀態機從 created 開始,但 created 之前其實還有「API 收到請求、決定要不要長出一筆 intent」這一步。Day 1 那個惡夢就發生在這邊:客戶端的 server 網路不穩,對同一筆 $50,000 USDC 的付款請求重送了三次。狀態機的機制管不到這裡,因為對它來說那是三筆不同的 intent,每一筆都合法。

今天要做的就是讓這三次請求只長出一筆 intent,而且三次都拿到同一個結果。方法是設計 Idempotency Key(下面照系列的習慣簡稱 idpk),讓客戶端替「這次支付」命名一個識別碼,放在 HTTP header 裡送進來。

今天的目標

支付 API 設計這塊 Stripe 是大前輩,公開文件也寫得很細,今天很多決定直接沿用他們的做法。

我們在 repo 裡面跑 make api-run,起一個簡單的 Payment API,然後對它送三次一模一樣的請求:

$ curl -si -X POST localhost:8080/v1/payment_intents \
    -H 'Authorization: Bearer merchant-demo' -H 'Idempotency-Key: order-1001' \
    -d '{"chain":"evm:31337","token":"0x5FbD…0aa3","payer":"0x7099…79C8","merchant":"0x3C44…93BC","amount":"100000000","expires_in_seconds":900}'
HTTP/1.1 201 Created
Location: /v1/payment_intents/pi_bfe74542f6b8b55f8f849a22
{"id":"pi_bfe74542f6b8b55f8f849a22","state":"created","version":1,"amount":"100000000",…}
 
$ (同一個指令再送一次)
HTTP/1.1 201 Created
Idempotent-Replayed: true
Location: /v1/payment_intents/pi_bfe74542f6b8b55f8f849a22
{"id":"pi_bfe74542f6b8b55f8f849a22","state":"created","version":1,"amount":"100000000",…}
 
$ (同一個 idpk,金額改成 999000000)
HTTP/1.1 422 Unprocessable Entity
{"error":"idempotency_key_reused","detail":"Idempotency-Key \"order-1001\" was already used for a different request (fingerprint ff330e75a93099c4, got 90f56163d6f6ee6b)"}

第二次拿到的 body 跟第一次逐 byte 一樣,只多了一個 Idempotent-Replayed: true 的 header 告訴他這是重放;第三次拿同一個 idpk 換金額,直接被擋下來。我們可以看到伺服器的 log 分別是 201201 replayed422

今天的程式碼在 repo 的 backend/internal/idempotency(本體其實就是一個 net/http middleware 加一個 store)與 backend/internal/api(只有建立與查詢 intent)。目前的 API 每次被叫到都建一筆新的 intent,保證它不被重複叫到的邏輯,全部在外面那層 middleware。

名詞定義

今天比較多需要簡寫的名詞,所以我們先定義一下:

  • idpk 是客戶端提交的識別碼,如果請求附帶同樣的識別碼就代表這是同一個意圖
  • scope 是這個 idpk 的擁有者,今天先拿 Authorization: Bearer 後面那個字串當 scope
  • fingerprint 是「這個請求長什麼樣」的識別,其實就是 method、path 與原始 body 三樣做一次 SHA-256 雜湊
  • record 是伺服器替 (scope, idpk) 記下的那一筆:fingerprint、狀態(跑到一半 or 已完成當前階段)、當前結果

整個機制能保證的只有「客戶端保證同一個 idpk 代表同一件事」以及「伺服器保證同一個 idpk 只執行一次且每次都回應同一個結果」。

問題

其實冪等性(Idempotence)的概念簡單,但實作上我們有很多個問題要解決:

第一個問題:這個 idpk 是誰的

答:idpk 只在 scope 內唯一,不是全域唯一(若兩個 merchant 同時用 order-1001 當 idpk 依然互不影響)。

為什麼不能全域?因為 idpk 是客戶端取的,伺服器管不到它的亂度。Stripe 建議用 UUID v4,但總會有人用 123 當 idpk(或者直接拿自己資料庫的自增 id 等等)。idpk 全域唯一的話,別人只要猜到你的 idpk,就能用它讀到你的結果(裡面有 intent id、金額、雙方地址),或者用同 idpk 不同 body 先送一發,把你的 idpk 卡成 422。標題裡的「重試攻擊」不只是自己人的重送,也包含這種拿別人的 idpk 來重送。所以 scope 一定要從憑證來,不能從 body 來,沒有憑證的請求連 idpk 都不收(回 401)。IETF 的草案也是這樣寫的:「Uniqueness of the key MUST be defined by the resource owner」。

第二個問題:如何處理同樣的 idpk + 不同的請求

答:同 idpk 不同 fingerprint 要拒絕。這是客戶端的 bug(把上一筆訂單的 idpk 拿來付下一筆)。

一般來說有三種不同處理方式:

選 422(狀態碼沿用 IETF 草案)。fingerprint 怎麼算是這裡的另一個小取捨:對原始 body bytes 做摘要,不解析 JSON。所以 {"a":1,"b":2}{"b":2,"a":1} 會被當成兩個請求、吃到 422。這會多出一些其實一樣卻被判不一樣的誤殺,但反過來(不一樣卻被判一樣)就是付錯錢,而且 middleware 不用知道每個 endpoint 的 body 長什麼樣。寧可誤殺不可放過的概念。

第三個問題:如何處理三個以上同時送達的請求

這個狀況應該是最常見的,客戶端可能 timeout 後連開三條連線、load balancer 又重放一次,結果一模一樣的合法交易全部擠在同一毫秒。

答:用 Claim 去認領一次執行機會。它做的事是先查這個 idpk 存不存在、不存在就寫入 in_flight,而且這兩步是同一個原子操作,所以同一個 (scope, idpk) 同時只會有一個請求拿到執行權。

// Claim 替 (scope, key, fp) 認領一次執行機會。四種結果:
//   fresh      沒見過(或上一筆已過期),紀錄已寫成 in_flight,去跑 handler
//   replay     同 idpk、同 fingerprint、已有結果,直接回 Record.Response
//   in_flight  同 idpk、同 fingerprint,原請求還在跑,回 409
//   mismatch   同 idpk、不同 fingerprint,回 422
func (s *MemoryStore) Claim(ctx context.Context, scope Scope, key Key, fp Fingerprint,
    now time.Time, policy Policy) (Record, Outcome, error)

整個 middleware 就是照 Claim 的結果分流:

注意順序:先 Claim 再跑 handler,不是跑完 handler 才記。這跟昨天「先記 settling 再廣播」是同一個道理,副作用還沒發生之前先把位子佔住,佔不到就放手。撞上還在跑的那個請求時回 409 而不是等它跑完,是因為客戶端本來就有重試邏輯,給它一個 Retry-After 比在伺服器上掛著一堆連線便宜。測試裡五十個同 idpk 請求同時打進來,結果只有兩種:201(第一個、或後來的重放)與 409,intent 只有一筆。

第四個問題:應該幫狀態儲存什麼結果

答:存整個回應(狀態碼、header、body),而且不管 handler 回 2xx、4xx 還是 5xx 都存。

4xx 好理解:金額填 0 拿到 400,同 idpk 再送一次還是那個 400,因為同樣的請求就該有同樣的結果,客戶端修好 body 之後要換一個新 idpk。

5xx 比較違反直覺,一個 500 明明是伺服器的錯,為什麼不讓客戶端重試?因為 500 代表「不知道有沒有做」:intent 可能已經存進去、只是回應寫到一半掛了。重放同一個 500 會逼客戶端注意到這件事;放它重跑,就是拿一筆可能已經存在的 intent 去賭。Stripe 的公開行為也是這樣,500 一樣存、一樣重放,文件直接建議「treat the result of a 500 request as indeterminate」。代價是一個暫時性的錯誤會把那個 idpk 卡 24 小時,客戶端得知情地換 idpk。

反過來,去重層自己回的 400、401、409、422 都不存。它們發生在 handler 開始執行之前,什麼副作用都沒有,客戶端修正之後拿同一個 idpk 再來還有機會(Stripe 有定義一個原則是「We save results only after the execution of an endpoint begins」)。

第五個問題:收到請求但執行到一半服務死掉了

這問題會發生在服務死在 Claim 之後、Complete 之前,所以有 record 會停在 in_flight,之後每一次重試都撞 409,撞到 24 小時後過期為止。

答:處理方式是每次 Claim 都帶一個 lease(這裡設 30 秒)與一個 attempt 計次。lease 的時間到了還沒返還結果,下一次重試就可以接手了(attempt += 1 然後 handler 再跑一次)。原本那個 worker 要是之後恢復後想寫入結果,attempt 會對不上、就寫不進去,然後它的客戶端會拿到一個 409(回想一下 CAS,跟 intent 的 Version 同一招;一個 idpk 只有一個結果會離開伺服器)。

重跑多出來的那筆 created intent 無害:沒有人會拿它的 payload 去簽名,動不了錢,逾時後由 systemcreated → canceled 砍掉。之後換成資料庫時,record 的完成與 intent 的寫入會放在同一個 transaction 裡,死在半路等於兩個都沒寫進去,這條接手的路幾乎不會走到(可以參考 Brandur 寫 Stripe 式 idempotency key 的那篇,講的就是這件事:「Atomic phases should be safely committed before initiating any foreign state mutation」)。

第六個問題:為什麼 idpk 不是 intent 的 id

答:Intent.ID 跟 idpk 是兩個東西,不能拿一個推另一個(但我知道實務上還是有人會想直接拿 idpk 當 primary key 省一個欄位)。

idpk 是客戶端取的、只在 scope 內唯一、24 小時後可以回收再用(Stripe 公開的期限就是 24 小時,過期後同一個 idpk 會被當成新請求);intent id 是伺服器產生的、全系統唯一、永遠不變,之後 ledger 與 queue 都拿它認這筆付款。兩者的擁有者不同、唯一性的範圍不同、壽命也不同。record 與 intent 之間唯一的連結是「結果裡寫著 id」,去重層甚至不知道自己保護的是 intent;這也是為什麼它可以原封不動地包在之後每一個會產生副作用的 endpoint 外面。

小結

今天聊的是 created 之前那一部分:idpk 只在 scope 內唯一、同 idpk 不同 body 要拒絕、Claim 是唯一的原子點且先佔位再執行、結果不論對錯都存、掛在半路靠 lease 與 attempt 接手。idpk 是一把短命的鑰匙:只活在 API 邊界、只活 24 小時,工作是把三次請求收成一次。

明天講另一種 key,那是從 API 進來、穿過 ledger 與 queue、寫進鏈上交易、最後被 listener 撈回來、壽命較長的 key。

明天見。


上一篇
Day 4 | 核心狀態機:支付意圖 (Payment Intent) 的生命週期流轉
下一篇
Day 6 | 雙鍵設計 (下):以 PaymentRef 作為 Audit Tracking 的識別碼
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言