Справочник API

Защита от двойных списаний

Идемпотентность означает, что повторный запрос выполняется один раз: передайте при повторе тот же Idempotency-Key, и списание будет только одно, даже если первая попытка лишь выглядела потерянной.

Запрос с тайм-аутом — не то же самое, что неудачный запрос. Генерация может уже идти, а повтор вслепую запустит — и оплатит — вторую. Передайте Idempotency-Key, и шлюз выполнит запрос один раз, а затем 24 часа будет возвращать тот же ответ.

#Когда передавать ключ

Передавайте

  • При каждой отправке асинхронной задачи — видео, изображения, музыка, 3D. Это самые дорогие запросы.
  • В любом непотоковом вызове, который ваш воркер повторяет автоматически.
  • В вызовах из очереди, которая может повторно выдать задание после сбоя.

Не нужен

  • Потоковые запросы — заголовок отклоняется с 400, поток нельзя воспроизвести повторно.
  • GET-опрос и чтение метаданных — их повтор и так бесплатен и безопасен.
  • Интерактивный чат, где лишний ответ обходится дешевле, чем учёт ключей.
submit.sh
curl https://www.aisplice.store/v1/videos \
  -H "Authorization: Bearer sk-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "doubao-seedance-2.5",
    "prompt": "A drone shot of a lighthouse at dawn"
  }'

Формируйте ключ из своей записи

UUID, сгенерированный внутри цикла повторов, лишает ключ смысла — каждая попытка получает новую идентичность. Формируйте ключ из бизнес-записи, которую вы обрабатываете (ID заказа, ID задания), чтобы все повторы этого задания шли с одним ключом.

#Как сопоставляются ключи

Включается явно, не автоматически

Без заголовка ничего не меняется. Повтор без ключа — это второй платный запрос.

Только методы записи

GET, HEAD и OPTIONS игнорируют заголовок — опрос задачи и так можно безопасно повторять.

Область действия: пользователь + ключ + метод + маршрут

То же значение с другим API-ключом или на другом эндпоинте — это другая операция.

Тело входит в идентификацию запроса

Путь, query-параметры и тело хешируются вместе. Повторное использование ключа с изменёнными данными вернёт 409, а не перезапишет запрос.

Несовместимо с потоковой передачей

Потоковый ответ нельзя сохранить для воспроизведения, поэтому stream:true вместе с ключом отклоняется с кодом 400.

При сбое — отказ

Если журнал недоступен, запрос отклоняется с кодом 503, а не выполняется без защиты.

Длина ключа — не более 255 символов. Более длинный ключ отклоняется с 400 до выполнения запроса.

#Результаты и коды статуса

Повторно возвращённый ответ содержит X-Idempotent-Replayed: true и исходный статус — считайте его ответом, а не дубликатом, требующим сверки. Все отказы используют плоский формат предварительной проверки.

HTTPКодЗначениеЧто делать
400IDEMPOTENCY_STREAM_UNSUPPORTEDIdempotency-Key передан в потоковом запросе.Уберите заголовок из потоковых вызовов или задайте stream:false, если нужна защита от повторов.
400IDEMPOTENCY_BODY_UNREADABLEНе удалось вычислить хеш тела для журнала идемпотентности.Отправляйте обычное тело JSON или multipart — chunked-тела, которые нельзя буферизовать, отклоняются.
409IDEMPOTENCY_PAYLOAD_CONFLICTЭтот ключ уже использовался с другим телом, путём или query-параметрами.Сгенерируйте новый ключ. Ключ обозначает один конкретный запрос, а не цикл повторов.
409IDEMPOTENCY_IN_PROGRESSПервый запрос с этим ключом ещё выполняется.Подождите Retry-After (2 с) и повторите запрос с тем же ключом — вы получите исходный ответ.
409IDEMPOTENCY_RESULT_UNREPLAYABLEИсходный запрос завершён, но его ответ закрыт и не может быть воспроизведён.Не отправляйте запрос повторно вслепую. Сначала сверьтесь с эндпоинтом задачи или своими записями.
503IDEMPOTENCY_UNAVAILABLEЖурнал идемпотентности недоступен, поэтому проверка отклоняет запрос, чтобы не рисковать двойным списанием.Повторите с увеличивающейся задержкой — запрос не выполнялся.

#Цикл повторов

Один ключ на задание, одинаковый во всех попытках; 409 «ещё выполняется» обрабатывается как «подождать», а не как «ошибка».

submit_once.py
import time, uuid, requests

def submit_once(job_id: str, body: dict, api_key: str, tries: int = 5):
    # Same key for every attempt of this job — that is what makes it safe.
    key = str(uuid.uuid5(uuid.NAMESPACE_URL, f"video-job:{job_id}"))

    for attempt in range(tries):
        response = requests.post(
            f"https://www.aisplice.store/v1/videos",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Idempotency-Key": key,
            },
            json=body,
            timeout=60,
        )

        if response.status_code == 409:
            code = response.json().get("code", "")
            if code == "IDEMPOTENCY_IN_PROGRESS":
                time.sleep(int(response.headers.get("Retry-After", 2)))
                continue
            # PAYLOAD_CONFLICT or RESULT_UNREPLAYABLE: a human decision,
            # never a silent resubmit.
            raise RuntimeError(response.text)

        if response.status_code >= 500:
            time.sleep(2 ** attempt)
            continue

        response.raise_for_status()
        replayed = response.headers.get("X-Idempotent-Replayed") == "true"
        return response.json(), replayed

    raise TimeoutError(f"submit for {job_id} did not settle")

Сохраните ID задачи, прежде чем что-либо повторять

Записывайте task_id в базу в той же транзакции, которая помечает задание как отправленное. Ключ защищает отправку, но если ответ потерян, защитит только ваша собственная запись.

Жизненный цикл асинхронной задачи →

Проблема не решена?

Пришлите нам ID запроса или текст ошибки, и мы разберёмся.