冪等性
タイムアウト後の再送で、生成や支払いを二重にしないための方法。
冪等性キーは「注文 8814 の動画を生成」のような 1 つの作業へ付ける識別子です。同じ識別子の要求が 2 回届いても、作業は 1 回行います。
createTask は Idempotency-Key ヘッダーで受け取ります。同じアカウントの同じ API キーから同じキーで 2 回送ると、最初に作成したタスクを返します。待機列、予約額、課金は追加されません。
POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1キーは送った内容、つまりモデル、全 input、callBackUrl に結び付きます。項目順や空白は影響しませんが、実際の変更は影響します。同じキーで別の要求を送ると 409、Idempotency-Key has already been used for a different request を返します。この 409 は再送しても成功しません。本当に変えた要求は、新しい作業として新しいキーが必要です。
必要な理由
タイムアウトでは、要求が届かなかったのか、届いて応答だけ失われたのか分かりません。キーがなければ、再送せずユーザーを何もないまま待たせるか、再送して二重生成・二重払いの危険を取るかの、困った選択になります。
キーがあれば安全に再送できます。タスクが作られるか、少し前に作られたものが返ります。
ヘッダーを読むエンドポイント
| エンドポイント | 同じキーで再送したとき |
|---|---|
POST /api/v1/jobs/createTask | 元のタスクを現在の状態で返す |
POST /api/v1/jobs/retry | 再試行で作成したタスクを返す。元タスクが範囲なので、違う元タスクの同じキーとは競合しない |
POST /v1/videos | 元のタスクを返す |
POST /api/v1/jobs/stream と /v1・/v1beta のテキスト | 400 を返す。下記参照 |
読み取り、アップロード・ダウンロードリンクの発行は、このヘッダーを無視します。POST /api/v1/jobs/purge は同じ taskId に繰り返しても問題がないため、キーは不要です。
テキストとストリーミングでは二重課金を防ぎますが、失った回答は戻せません
テキストの回答は生成しながら送られ、保存されません。完了済みのキーを同じ本文で再送すると、400(This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request)を返し、追加課金はありませんが、回答はどこからも取得できません。以前の要求が失敗していたなら、同じエラーを返します。新しいキーで試すか判断できます。本文が違えば 409 です。
これらでは、新しいメッセージごとに新しいキーを使い、ネットワークエラー後の完全に同じ要求だけ、回答を失う可能性を受け入れてキーを再利用します。回答がもう一度必要なら、新しいキーで送り、料金を払います。テキストとストリーミングを参照してください。
有効期間は 24 時間
最初に受理された時点から 24 時間有効です。再送では延長しません。その後は同じキーでも新規要求として新しい課金対象タスクを作るため、十分早く自動再試行を止めます。
24 時間はバランスを取った期間です。クライアント再試行は通常数秒~数分で終わり、その範囲を覆えば足ります。一方、長いほど再利用できない期間も延び、固定のキーなら 1 日 1 タスクしか作れません。
キーの選び方
- HTTP 呼び出しごとでなく、論理的な要求 1 つに 1 キーを使います。再試行で同じキーを使わなければ意味がありません。
- 既存の識別子から作ります。
order-8814-render-1は再起動後も再計算でき、無作為 UUID より便利です。 - 元になるものがなければ、最初の試行前に UUID を作り、注文の横などデータベースへ保存します。再試行は保存値を読みます。
- アカウント内で確保するため全世界で固有にする必要はありませんが、同じ論理要求の再試行では同じ API キーも必要です。最大 128 文字です。
同じアカウントの別 API キーでは再取得できません
冪等性の対応はアカウント全体で確保し、タスクの可視範囲は API キーで分離します。別の API キーが確保済みの冪等性キーを使うと、409 Conflict(Idempotency-Key is already in use by this account)を返し、元のタスク ID は明かしません。再送で他の連携のタスクを探られないためです。再試行では直らず、最初の試行と同じ API キーで送ります。
再試行ループ内でキーを生成しないでください
// Wrong: a new key per attempt, which defeats the whole mechanism
for (let i = 0; i < 3; i++) {
await create({ 'Idempotency-Key': crypto.randomUUID() });
}
// Right: generate once, outside the loop
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
await create({ 'Idempotency-Key': key });
}再送の応答
createTask は同じ taskId と estimatedCost を返しますが、state は元のタスクの現在の状態です。queued、running、終端のいずれもあり得ます。新しいタスク、予約、課金は作りません。
再送だったことを示すヘッダーや項目はありません。全記録を読むには、taskId を recordInfo へ渡します。
同じキーで同時に送った場合
同じ API キーと同じ冪等性キーの 2 要求が同時に届くと、片方だけが作成し、もう片方には同じ taskId を返します。確認してから挿入する方式ではなく、データベースの一意制約で決めます。前者では同時確認が両方空になり、2 タスクと 2 予約ができてしまうためです。
まれに競合が次の形で表れます。
{ "code": 409, "msg": "Idempotency key conflict, please retry shortly" }数百ミリ秒待ち、同じキーで再試行します。
他の 2 種類の createTask の 409 は再試行しても直りません。Idempotency-Key has already been used for a different request は要求の変更、Idempotency-Key is already in use by this account は別の API キーによる使用です。3 種類とも code: 409 で、msg の文章による分岐は避けます。409 の再試行は最大 1~2 回にし、その後は人が確認できるエラーにします。
完全な例
関数はキーを引数で受け取ります。例の注文 ID のような自分の記録から作り、再起動後も同じキーを送れるようにします。
// These are business codes from the response body, not HTTP statuses.
const RETRYABLE = new Set([429, 500, 50301]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (attempt) => sleep(2 ** attempt * 500 + Math.random() * 300);
async function createTaskWithRetry(payload, idempotencyKey, { attempts = 4 } = {}) {
let conflicts = 0;
for (let i = 0; i < attempts; i++) {
// Declared outside try so the checks below can read them.
let res;
let body;
try {
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 {
// Transport failure or unreadable body: the request may well have landed.
// Retrying with the same key is safe.
await backoff(i);
continue;
}
if (res.status === 202 && body.code === 200) return body.data;
// Only one of the three 409s is a transient race, so cap 409 retries at one.
const retry = body.code === 409 ? ++conflicts <= 1 : RETRYABLE.has(body.code);
if (!retry) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
await backoff(i);
}
// Still unknown whether the task exists. Try again later with the SAME key.
throw new Error(`out of retries for Idempotency-Key ${idempotencyKey}`);
}
// payload is { model, input } built from the live catalog; order is your own record.
// The key is derived from the order, so it is identical after a restart.
const task = await createTaskWithRetry(payload, `order-${order.id}-render-1`);
console.log(task.taskId);import os, random, time, requests
# These are business codes from the response body, not HTTP statuses.
RETRYABLE = {429, 500, 50301}
def create_task_with_retry(payload: dict, idempotency_key: str, attempts: int = 4) -> dict:
conflicts = 0
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, ValueError):
# Transport failure or unreadable body: the request may well have landed.
# Retrying with the same key is safe.
time.sleep(2**i * 0.5 + random.random() * 0.3)
continue
if res.status_code == 202 and body["code"] == 200:
return body["data"]
# Only one of the three 409s is a transient race, so cap 409 retries at one.
if body["code"] == 409:
conflicts += 1
retry = conflicts <= 1
else:
retry = body["code"] in RETRYABLE
if not retry:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
time.sleep(2**i * 0.5 + random.random() * 0.3)
# Still unknown whether the task exists. Try again later with the SAME key.
raise RuntimeError(f"out of retries for Idempotency-Key {idempotency_key}")
# payload is {"model": ..., "input": ...} built from the live catalog; order_id is yours.
# The key is derived from the order, so it is identical after a restart.
task = create_task_with_retry(payload, f"order-{order_id}-render-1")
print(task["taskId"])通信の失敗でも同じキーを使います
fetch の例外、接続タイムアウト、読み取りタイムアウトでは、要求が届いたか分かりません。その不確実さのためのキーです。同じキーで再試行すれば、最悪でも先のタスクが返ります。回数を使い切ってもキーを保存します。24 時間以内の後の呼び出しでも同じタスクを返します。

