分佈式系統裏"沒有收到響應"不等於"請求沒有成功"。你發出下單請求後連接超時,可能是請求根本沒到,也可能是訂單已經創建、只是響應在回程中丟了——客戶端無法區分這兩種情況。
KukoPay 提供兩層冪等,它們解決的問題不同,建議同時使用。
| 機制 | 位置 | 作用範圍 | 有效期 |
|---|---|---|---|
out_trade_no / out_refund_no | 請求體 | 只覆蓋下單和退款 | 永久 |
Idempotency-Key | 請求頭 | 所有寫接口 | 24 小時 |
業務級:out_trade_no
out_trade_no 是商戶訂單號,同時也是當前應用、當前環境中的冪等鍵。同一個值攜帶相同訂單內容重複提交時,接口返回首次創建的訂單,不會重複創建或扣款。若金額、幣種、標題或回調地址不同,接口返回 409 conflict,請先查詢原訂單;新訂單應使用新號碼。
{
"out_trade_no": "ORDER_20260921_0001",
"amount": 2990,
"currency": "USD"
}測試和正式環境相互隔離,同一賬戶下的不同應用也相互隔離,因此它們可以分別使用相同的 out_trade_no。out_refund_no 同樣在應用內唯一。
正式環境的退款必須提供 out_refund_no 並以它作為冪等鍵。一次退款的所有重試都使用同一個值;另一筆退款必須換新值。
傳輸級:Idempotency-Key
在任何寫接口上加一個 Idempotency-Key 請求頭即可,值由你生成,推薦 UUID v4。Key 按應用隔離,不同應用使用相同的 Key 互不影響。平台會存下首次的響應並在 24 小時內原樣重放,並且校驗請求體是否一致。
curl -X POST "https://api.kukopay.com/v1/orders" \
-H "X-Api-Key: kuko_test_你的密鑰" \
-H "Idempotency-Key: 8f14e45f-ea2a-4c6f-9d3b-1b7c2a55e901" \
-H "Content-Type: application/json" \
-d '{
"out_trade_no": "ORDER_20260921_0001",
"amount": 2990,
"currency": "USD",
"subject": "Pro 年度訂閲"
}'| 規則 | 説明 |
|---|---|
| 取值 | 不超過 255 個可打印 ASCII 字符,推薦 UUID v4 |
| 作用範圍 | 按 商戶 + 環境 + 接口 隔離,互不干擾 |
| 保留時長 | 24 小時,之後該值可以被重新使用 |
命中重放時
平台原樣返回首次的狀態碼和響應體,並額外帶上 Idempotent-Replayed: true。注意 request_id 也是首次那一次的——這正是它有用的地方:你日誌裏的兩條記錄會指向同一次真實執行。
HTTP/1.1 201 Created
X-Request-Id: req_9f2c1a7b4e8d0356c1ab77de90f4b215
Idempotent-Replayed: true同一個 Key 配不同的請求體
這是平台會明確拒絕的唯一情況,因為它一定意味着調用方出了 bug。
{
"code": 400,
"error": "idempotency_error",
"type": "idempotency_error",
"message": "該 Idempotency-Key 已被用於內容不同的請求。",
"param": "Idempotency-Key",
"request_id": "req_31b6c0d7f2a94e15b8c3d0a7e6f45219"
}重試時不要重新生成時間戳、隨機數或簽名字段。如果請求體必然會變,那就不要複用 Key——那本來就是一次新的請求。
併發使用同一個 Key
首次請求還在處理中時,第二次請求會立刻得到 409 idempotency_in_progress,而不是排隊等待。稍後用同一個 Key 重試即可拿到最終結果。
哪些結果會被緩存
| 響應 | 行為 |
|---|---|
2xx | 存下並在 24 小時內重放 |
4xx | 同樣存下並重放——請求已有確定結論,重試不會有不同結果 |
5xx | 不緩存,Key 被釋放。用同一個 Key 重試會真正執行 |
一個完整的重試實現
async function createOrder(payload) {
// 每一筆業務訂單生成一次,重試時複用同一個值
const idempotencyKey = crypto.randomUUID()
const delays = [1000, 2000, 4000, 8000]
for (let attempt = 0; ; attempt++) {
const res = await fetch("https://api.kukopay.com/v1/orders", {
method: "POST",
headers: {
"X-Api-Key": process.env.KUKOPAY_API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
const body = await res.json()
// 把 request_id 記進日誌,反饋問題時平台靠它定位
console.info("kukopay", res.status, body.request_id)
if (res.ok) return body.data
// 409 表示同一個 Key 正在處理中;5xx 表示結果未知——都可以原樣重試
const retriable = res.status === 409 || res.status >= 500
if (!retriable || attempt >= delays.length) throw new Error(body.message)
await new Promise((r) => setTimeout(r, delays[attempt]))
}
}能否重試請看 錯誤碼 頁的 type 字段:api_error 與 api_connection_error 可以安全重試,invalid_request_error 修正參數前重試沒有意義。
Webhook 消費
Webhook 可能因為超時或非 2xx 響應被重投。請在業務數據庫為事件 id 建立唯一約束,並在同一事務內完成"寫入事件 ID"和"業務履約"。
不要只在內存裏去重,也不要先發貨再記錄事件。進程重啓或併發通知會繞過這兩種做法。