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

Кредиты списывает один маршрут — POST /v1/generations. Он требует заголовок Idempotency-Key, и именно этот заголовок делает повтор после потерянного ответа безопасным: сеть оборвалась, клиент не узнал, дошёл ли запрос, отправил его ещё раз — и получил ту же генерацию, а не вторую оплаченную.

Маршруту нужна область generations:write, а аккаунту — активный платный тариф: без него новый запрос отвечает 402 с кодом payment_required, а точный повтор уже принятого ключа по-прежнему возвращает сохранённый ответ. Ни один другой маршрут ключа не требует и ничего не списывает.

Ключ обязателен

В отличие от многих API, здесь заголовок не опция: запрос без него отклоняется до того, как что-то будет создано или списано. Раньше него отвечают общие проверки — доступ, оплаченность аккаунта, лимит запросов и разбор тела, — так что отказ по ним придёт вместо этого. Повтор, пришедший без ключа, неотличим от второго запроса, а разница между ними — второе списание.

curl -s -i -X POST https://api.coremind.ru/v1/generations \
  -H "Authorization: Bearer cmk_..." \
  -H "Idempotency-Key: 8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11" \
  -H "Content-Type: application/json" \
  -d '{"type": "image", "model": "<model_id>", "prompt": "..."}'

Без заголовка тот же запрос не доходит до работы:

HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "invalid_request",
    "message": "The request parameters are not valid.",
    "user_message": "Параметры запроса некорректны.",
    "request_id": "8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11",
    "details": [
      {
        "field": "Idempotency-Key",
        "reason": "required",
        "message": "This header is required on every request that spends."
      }
    ]
  }
}

Что идентифицирует ключ

Значение выбирает вызывающий — берите UUID. Ключ действует в пределах вашего аккаунта и одного type из тела запроса: один и тот же ключ на image и на video — два разных запроса.

Ключ привязан не только к себе, но и к телу, с которым пришёл первым. Отсюда три исхода:

  • тот же ключ и то же тело — повтор первой отправки, списания нет. Одно исключение: отменённая до старта работы генерация освобождает свой ключ намеренно — тот же ключ после отмены создаёт новую генерацию и считается заново, ровно чтобы отменённое можно было отправить снова;
  • тот же ключ и другое тело — отказ 409, ничего не создано;
  • новый ключ — новый запрос, который считает цену заново, даже если тело совпадает байт в байт: столько же кредитов, либо бесплатная квота или промо-режим, если они покрывают этот запрос. Второй дубль того же промпта получают именно так.

Что возвращает повтор

Ответ на принятую отправку — 202, никогда 201: строка создана, работа ещё нет. Location указывает на GET /v1/generations/{id} — маршрут, который отдаёт результат; о смене статуса сообщает поток событий. Поверхность, которую этот API не отправляет, отвечает 503 с кодом submit_unavailable, называет type и ничего не списывает.

HTTP/1.1 202 Accepted
Location: /v1/generations/<generation_id>

{
  "id": "<generation_id>",
  "type": "image",
  "status": "PENDING",
  "model_id": "<model_id>",
  ...
}

Повтор с тем же ключом и тем же телом отвечает так же: тот же 202, тот же Location и та же самая запись — в том статусе, в котором она сейчас. Отдельной формы «уже сделано» здесь нет намеренно: клиент, которому на повтор отвечают иначе, учится не повторять.

Статус ответа при этом остаётся 202, каким бы ни был статус самой записи. Повтор по генерации, которая успела провалиться, — это 202 с записью в статусе FAILED и заполненным error.reason: причину нужно читать из тела, а не выводить из HTTP-статуса. Словарь причин — на странице кодов ошибок.

Конфликт ключа

Ключ, уже занятый другим телом, отвечает 409 с кодом duplicate_key_conflict. В details названы поле Idempotency-Key, причина in_use и генерация, за которой этот ключ закреплён. Чинится это не повтором, а новым ключом.

HTTP/1.1 409 Conflict

{
  "error": {
    "code": "duplicate_key_conflict",
    "message": "This Idempotency-Key was already used with a different request body.",
    "user_message": "Этот Idempotency-Key уже использован с другим телом запроса.",
    "request_id": "8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11",
    "details": [
      {
        "field": "Idempotency-Key",
        "reason": "in_use",
        "message": "This key already identifies generation <generation_id>."
      }
    ]
  }
}

Повторы на остальных маршрутах

Ключ нужен только отправке, потому что списывает только она. У остальных записывающих маршрутов повтор устроен по-своему:

  • POST /v1/pricing/estimate ничего не создаёт и не тратит кредитов, но расходует общий лимит запросов, как любой вызов /v1. Что он отвечает, описано в быстром старте.
  • POST /v1/uploads подписывает ровно один объект: каждый вызов выдаёт новый билет, а повторный PUT по уже записанному адресу отклоняет само хранилище. Повторять нужно с новым билетом, а не с тем же.
  • POST /v1/generations/{id}/report принимает client_msg_id — UUID, по которому повтор возвращает уже заведённое обращение вместо второго. Подробности — на странице кодов ошибок.
  • POST /v1/support/messages тоже принимает client_msg_id — UUID: повтор возвращает уже отправленное сообщение с теми же картинками вместо второго, а тот же идентификатор с другим текстом или другим числом картинок отвечает support_replay_mismatch. Картинки — это reference-и из POST /v1/uploads.
  • DELETE /v1/generations/{id} отменяет генерацию, пока её ещё не забрали в работу. Повтор на уже удалённой записи — успех, а не конфликт. Ответ { refunded } сообщает, вернулись ли кредиты: маршрут сообщает о движении, а не решает его. Запись, которую уже забрал исполнитель, отвечает generation_in_flight, придержанная администратором — generation_frozen. Завершённая запись удаляется вместе с готовыми файлами, как в интерфейсе. Маршруту нужны та же область generations:write и тот же активный тариф, что и отправке: ключ, которому нельзя списывать, не может и возвращать.