冪等鍵
網路逾時之後安全重試,不重複生成、不重複扣費。
createTask 支援 Idempotency-Key 請求標頭。同一個帳號、同一把 API Key 用同一個鍵重複提交,回傳的是首次建立的那個任務:不重複排隊,也不重複凍結金額或收費。
POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1為什麼必須用它
網路逾時時你處在最壞的一種不確定裡:請求可能沒到、也可能到了但回應遺失了。不帶冪等鍵的話你只有兩個都不好的選擇——不重試(使用者白等),或者重試(可能生成兩次、扣兩次錢)。
帶上冪等鍵,重試就是安全的:要麼建立,要麼把上次建立的那個任務還給你。
只有會建立新任務的介面讀取這個標頭
createTask 和 retry 都支援 Idempotency-Key。retry 會把它限定在「來源任務 + retry 操作」的語義內,因此不同來源任務之間不會衝突。查詢和簽發臨時憑證的介面不讀這個標頭。
24 小時時間窗
一個鍵在 24 小時內有效。過了這個時間窗,同一個鍵會被當成一個新請求,建立一個新任務。
24 小時是兩件事的折衷:用戶端的重試通常在幾秒到幾分鐘內完成,時間窗夠長就行;而時間窗越長,同一個鍵就越久不能重複使用——用固定鍵做日常呼叫的話,你會發現自己一天只能提交一次任務。
鍵怎麼取
- 每個「邏輯請求」一個鍵,不是每次 HTTP 呼叫一個。重試時必須沿用同一個鍵,否則等於沒用。
- 用你自己業務裡已有的唯一識別碼:訂單編號 + 用途 + 序號(
order-8814-render-1)比隨機 UUID 更好用——程式重啟後你還能算出同一個鍵。 - 用不上業務識別碼時,在發起第一次請求之前產生一個 UUID 並持久儲存,重試時讀取同一個值。
- 鍵按帳號佔用,不需要全域唯一;但一次邏輯請求的所有重試還必須使用同一把 API Key。長度上限 128 字元。
同帳號的另一把 API Key 也不能重放
冪等鍵對應按帳號佔用,任務內容則按 API Key 隔離。同帳號的其他 Key 使用已佔用的冪等鍵會得到 409 Conflict,回應不會包含原任務 ID。這樣一把 Key 不能借冪等重放窺探另一把 Key 的任務。
別把亂數寫在重試迴圈裡面
// 錯的:每次重試都換了鍵,等於沒開冪等
for (let i = 0; i < 3; i++) {
await create({ 'Idempotency-Key': crypto.randomUUID() });
}
// 對的:鍵在迴圈外面產生
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
await create({ 'Idempotency-Key': key });
}命中重複時會看到什麼
回應回傳同一個 taskId 和 estimatedCost,但 state 是原任務的目前狀態,可能是 queued、running 或終態。重放不會再建立任務、重複凍結或重複收費。
沒有額外的標頭或欄位告訴你「這次是命中了冪等」。要讀取完整任務記錄,拿 taskId 去查 recordInfo。
並行提交同一個鍵
同一把 API Key 的兩個請求帶著同一個鍵同時到達時,只有一個會真正建立任務,另一個會拿到同一個 taskId。判定靠資料庫唯一約束而不是「先查再插」——後者在並行下兩次查詢都會回傳空,於是兩個任務都被建立、兩筆凍結都成立。
極少數情況下會回傳:
{ "code": 409, "msg": "Idempotency-Key is already in use by this account" }這表示鍵被佔用但對應的任務查不到。退避幾百毫秒重試同一個鍵即可。
完整範例
import crypto from 'node:crypto';
async function createTaskWithRetry(payload, { attempts = 4 } = {}) {
// 鍵在迴圈外面。這是整段程式碼的重點。
const idempotencyKey = crypto.randomUUID();
// 這裡判斷的是回應本文中的業務碼,不是 HTTP 狀態碼。
const retryable = new Set([409, 429, 500, 50301]);
for (let i = 0; i < attempts; i++) {
let body;
try {
const res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(payload),
});
body = await res.json();
} catch {
// 連線層失敗:請求可能已經到達。帶著同一個鍵重試是安全的。
await sleep(2 ** i * 500 + Math.random() * 300);
continue;
}
if (res.status === 202 && body.code === 200) return body.data;
if (!retryable.has(body.code)) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
await sleep(2 ** i * 500 + Math.random() * 300);
}
throw new Error('重試次數已用盡');
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));import os, random, time, uuid, requests
# 這裡判斷的是回應本文中的業務碼,不是 HTTP 狀態碼。
RETRYABLE = {409, 429, 500, 50301}
def create_task_with_retry(payload: dict, attempts: int = 4) -> dict:
# 鍵在迴圈外面。這是整段程式碼的重點。
idempotency_key = str(uuid.uuid4())
for i in range(attempts):
try:
res = requests.post(
"https://api.spicyapi.ai/api/v1/jobs/createTask",
headers={
"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json=payload,
timeout=30,
)
body = res.json()
except requests.RequestException:
# 連線層失敗:請求可能已經到達。帶著同一個鍵重試是安全的。
time.sleep(2**i * 0.5 + random.random() * 0.3)
continue
if res.status_code == 202 and body["code"] == 200:
return body["data"]
if body["code"] not in RETRYABLE:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
time.sleep(2**i * 0.5 + random.random() * 0.3)
raise RuntimeError("重試次數已用盡")連線層失敗也要帶同一個鍵重試
fetch 拋出例外、連線逾時、讀取逾時——這些情況下你不知道請求有沒有到達。這正是冪等鍵存在的意義:帶著同一個鍵重試,最壞情況也只是拿回上一次建立的任務。

