spicyapiДокументация
Разработка с API
Основное содержимое

Идемпотентность

Повтор запроса после тайм-аута без двойной генерации и двойной оплаты.

Ключ идемпотентности — метка одной работы, например «сгенерировать видео заказа 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, затем покажите ошибку человеку.

Полный пример

Функция принимает ключ аргументом. Вычисляйте из собственных записей, здесь идентификатора заказа, чтобы перезапуск снова отправил тот же ключ.

JavaScript
// 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);
Python
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-часового окна.

На этой странице