API で作る
本文冪等性
タイムアウト後も、二重生成や二重課金を心配せずに再試行する方法。
冪等キーは、「注文 8814 の動画をレンダリングする」のような 1 つの仕事に付けるラベルです。同じラベルのリクエストが PopNow に 2 回届いても、仕事は 1 回しか行われません。
POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1なぜ必要か
タイムアウトすると判断がつきません。リクエストが届かなかったのかもしれないし、届いたのに応答だけが失われたのかもしれません。キーがなければ、諦める(ユーザーを無駄に待たせる)か、再試行する(二重に生成・課金されるかもしれない)しかありません。キーがあれば再試行は常に安全で、新しいタスクができるか、直前に作られたタスクが返るかのどちらかです。
動作
- 同じ API キーから同じキーで送り直すと、元のタスクが現在の状態で返ります。タスク、確保、課金が増えることはありません。
- キーは送った内容(
model、input全体、callBackUrl)と結び付きます。フィールドの順序や空白は関係ありませんが、実質的な変更は別物です。変更したリクエストを古いキーで送ると409になるので、新しいキーを付けてください。 - キーは最初に使ってから 24 時間記憶されます。それを過ぎると同じキーでも新しい有料のタスクが作られるので、自動再試行はそれよりずっと前に止めてください。
- キーは 128 文字までで、アカウント内で一意であれば十分です。アカウントの別の API キーからは再利用できず、元のタスク ID なしの
409が返ります。
| エンドポイント | 同じキーで送り直すと… |
|---|---|
POST /api/v1/jobs/createTask | 元のタスクを返す |
POST /api/v1/jobs/retry | その再試行で作られたタスクを返す(キーの範囲は元のタスクごと) |
POST /v1/videos | 元のタスクを返す |
POST /api/v1/assets | すでに保存済みのアセットを返す(ここではヘッダーが必須) |
読み取り系や、アップロード・ダウンロード用リンクを発行するエンドポイントはこのヘッダーを無視します。jobs/purge には不要で、繰り返し呼んでも害はありません。
キーの選び方
- 仕事 1 つにつきキー 1 つです。HTTP の試行ごとではありません。再試行のたびに同じものを使います。
- すでに保存している値から作りましょう。
order-8814-render-1ならクラッシュ後も作り直せますが、ランダムな UUID は先に保存しておかない限り取り戻せません。 - UUID を使うなら、最初の試行より前に生成し、対応する仕事と一緒に保存してください。
再試行ループの中でキーを作らないでください
// Wrong: a new key per attempt defeats the whole mechanism
for (let i = 0; i < 3; i++) await createTask(body, crypto.randomUUID());
// Right: one key, created once
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) await createTask(body, key);衝突
以下はいずれも code: 409 を返します。
| 何が起きたか | 再試行する? |
|---|---|
| 同じキーの 2 つのリクエストが同時に届いた | する。数百ミリ秒待って同じキーで送り直す |
| そのキーが別のリクエストに使われていた | しない。リクエストが変わったので新しいキーが必要 |
| アカウントの別の API キーがこのキーを使っていた | しない。最初に試した API キーで再試行する |
msg で分岐すべきではないので、409 の再試行は 1〜2 回にとどめ、その後は人が確認できるようエラーを表に出してください。
完全な例
const RETRYABLE = new Set([429, 500, 503, 50301]); // business codes from the body
async function createTaskOnce(body, idempotencyKey) {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch('https://api.popnow.ai/api/v1/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POPNOW_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
const json = await res.json();
if (json.code === 200) return json.data; // a new task, or the original one
if (!RETRYABLE.has(json.code)) throw new Error(`${json.code} ${json.msg} (${json.request_id})`);
} catch (err) {
if (!(err instanceof TypeError)) throw err; // network error: safe to retry with the same key
}
await new Promise((r) => setTimeout(r, 2 ** attempt * 500 + Math.random() * 300));
}
throw new Error('createTask did not succeed after 4 attempts');
}
const task = await createTaskOnce(
{ model: 'alibaba/z-image-turbo/text-to-image', input: { prompt: 'A paper boat on a calm lake' } },
'order-8814-render-1',
);
