Идемпотентность
Повтор запроса после тайм-аута без двойной генерации и двойной оплаты.
Ключ идемпотентности — метка одной работы, например «сгенерировать видео заказа 8814». Если запрос с той же меткой приходит дважды, работа выполняется один раз.
createTask принимает его в заголовке Idempotency-Key. Две отправки одного ключа от одного аккаунта и одного API-ключа возвращают созданную в первый раз задачу: без второй очереди, резервирования и списания.
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 часа с первого принятия. Повторы не продлевают срок. Затем тот же ключ считается свежим запросом и создаёт новую оплачиваемую задачу, поэтому автоматические повторы остановите задолго до этого.
Двадцать четыре часа — компромисс. Клиентские повторы занимают секунды или минуты, достаточно охватить их. Но больший срок дольше запрещает повторное использование ключа: с жёстко заданным ключом вы отправляли бы одну задачу в день.
Выбор ключей
- Один ключ на логический запрос, не на HTTP-вызов. Повторы обязаны сохранять ключ, иначе механизм бесполезен.
- Выведите его из имеющегося идентификатора:
order-8814-render-1лучше случайного UUID, поскольку восстанавливается после перезапуска. - Если основы нет, создайте UUID до первой попытки и сохраните в базе рядом с заказом; повтор читает сохранённое.
- Ключи закрепляются внутри аккаунта и не обязаны быть глобально уникальными, но повторы одного логического запроса используют тот же API-ключ. Максимум — 128 символов.
Другой API-ключ аккаунта не восстановит задачу
Сопоставление идемпотентности закрепляется на уровне аккаунта, а видимость задач изолирована по API-ключу. Другой ключ с уже занятой меткой получает 409 Conflict (Idempotency-Key is already in use by this account) без раскрытия исходного идентификатора задачи. Так один ключ не исследует задачи другой интеграции через повтор. Повторять бесполезно: используйте 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-ключа с одной меткой ровно один создаёт задачу, другой получает тот же taskId. Решение основано на ограничении уникальности базы, а не «проверить и вставить»: при одновременности обе проверки могли бы быть пустыми, создавая две задачи и два резерва.
Изредка гонка видна так:
{ "code": 409, "msg": "Idempotency key conflict, please retry shortly" }Подождите несколько сотен миллисекунд и повторите с тем же ключом.
Два других 409 у createTask повторять не стоит: Idempotency-Key has already been used for a different request означает изменение запроса; Idempotency-Key is already in use by this account — занятость метки другим API-ключом аккаунта. Все три имеют code: 409, а код не должен ветвиться по msg, поэтому разрешите максимум один-два повтора 409, затем покажите ошибку человеку.
Полный пример
Функция принимает ключ аргументом. Вычисляйте из собственных записей, здесь идентификатора заказа, чтобы перезапуск снова отправил тот же ключ.
// 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-часового окна.
Вебхуки
Различайте тела вебхуков v1 и v2, выбирайте правильный ID задачи и проверяйте подписи с готовыми примерами для Node.js, Python и curl.
Расчёт цены и совместимость протоколов
Как узнать точную цену запроса до оплаты, отправить его ровно по этой цене, не заплатить дважды после потерянного ответа и как тарифицируются совместимые API для текста и видео.

