Задачи и долгие операции

Всё, кроме ответа чата, — задача с одним объектом (схема Job): его отдают ответ на запрос, GET /v1/jobs/{id}, поток событий и вебхук. status — queued, in_progress, completed или failed; data[] — файлы результата; price — null до завершения; error — той же формы, что тело ошибки.

Ответ сразу или позже

  • Картинки, музыка, транскрибация и речь отвечают результатом сразу — 200 с готовой задачей. Не успели за 9 минут от приёма запроса (загрузка тела входит в это время) — 202 с задачей в работе.
  • Видео — всегда 202.
  • Prefer: respond-async — не ждать: 202 сразу у любой задачи.
  • У любого 202 — заголовки Location: /v1/jobs/{id} и Retry-After (секунды до следующего опроса).
  • Провал во время ожидания — статус по причине (400, 402, 502, 503) и тот же объект задачи с полем error.
  • Отменить принятую задачу нельзя — метода отмены нет, DELETE /v1/jobs/{id} отвечает 405. Задача доделается и при успехе спишется; обрыв соединения и прекращение опроса её не останавливают.

Как забрать результат после 202

  1. Опрос — GET /v1/jobs/{id} с паузой в Retry-After, пока status не станет completed или failed.
  2. Поток событий — GET /v1/jobs/{id}/events, text/event-stream: первым событием — текущее состояние, затем новый снимок при каждом изменении и data: [DONE] в конце; раз в 15 секунд — комментарий : ping. Одно соединение живёт до 35 минут; переподключаться можно в любой момент. Поток закрылся без [DONE] (истёк срок соединения, обновление сервиса, обрыв сети) — переподключитесь: первым событием придёт текущее состояние.
  3. Вебхук — callback_url в запросе: вебхук по завершении задачи.
// EventSource не умеет заголовок Authorization — читаем поток через fetch.
// Поток закрылся без [DONE] — переподключаемся: первым придёт текущее состояние.
async function* jobEvents(apiKey: string, jobId: string) {
  for (;;) {
    let res: Response;
    try {
      res = await fetch(`https://api.souz.ai/v1/jobs/${jobId}/events`, { headers: { Authorization: `Bearer ${apiKey}` } });
    } catch {
      await new Promise((r) => setTimeout(r, 1000)); // сеть — пауза и снова
      continue;
    }
    if (!res.ok) throw new Error(`events: HTTP ${res.status}`);
    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    try {
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break; // без [DONE] — переподключиться
        buffer += decoder.decode(value, { stream: true });
        let cut: number;
        while ((cut = buffer.indexOf("\n\n")) !== -1) {
          const frame = buffer.slice(0, cut);
          buffer = buffer.slice(cut + 2);
          for (const line of frame.split("\n")) {
            if (!line.startsWith("data: ")) continue;
            const payload = line.slice(6);
            if (payload === "[DONE]") return;
            yield JSON.parse(payload);
          }
        }
      }
    } catch {
      // обрыв посреди потока — переподключиться
    }
    await new Promise((r) => setTimeout(r, 1000));
  }
}

Повтор без второй задачи — Idempotency-Key

Обрыв соединения задачу не отменяет. Повтор с тем же Idempotency-Key (строка до 255 символов, уникальная в аккаунте) вернёт ту же задачу с текущим статусом, без второй задачи и второго списания.

Повтор с тем же ключом отдаёт исходное задание независимо от изменений настроек аккаунта; сравниваются только поля запроса. Пресет, потолок цены и настройки каналов, с которыми задача принята, — в её полях routing, max_price_multiplier и routing_options.

  • Ключ длиннее 255 символов — 400 invalid_request с detail[].reason: too_long.
  • Отказ до приёма — ответ с одним error, без объекта задачи (400, 401, 402, 403, 429, 503): задачи нет, ключ не занят — исправьте запрос и повторите с тем же ключом. Ответ с объектом задачи, даже failed, ключ занимает.
  • Тот же ключ с другим запросом — 409 idempotency_key_conflict. Это ошибка в коде клиента: новый ключ автоматически не берите, найдите, почему запрос изменился. Сравниваются в том числе routing, routing_options и max_price_multiplier — как они указаны в запросе: поле, которого не было, и поле с тем же значением, что в настройках, — разные запросы. Поля доставки (store, callback_url) не сравниваются. У картинок, видео и музыки не сравнивается response_format, у речи формат звука сравнивается, у транскрибации — только «с таймкодами или без».
  • Повтор задачи со статусом failed возвращает её же; для новой попытки — новый ключ.
  • Работает для картинок, видео, музыки, транскрибации, речи и чата. Ключ хранится вместе с задачей и не продлевает срок файлов.
  • У чата сравнивается тело запроса целиком. Повтор во время вызова дожидается его и получает тот же ответ, после вызова — сразу. Ответ хранится сутки; позже — 502 со ссылкой на задачу, итог — в GET /v1/jobs/{id}.

Долгие модели и тяжёлые картинки — один путь кода

Модель, которой бывает мало 9 минут (видео, сбой канала), отвечает то 200, то 202. Отправляйте такие запросы с Prefer: respond-async и Idempotency-Key: ответ всегда 202, результат — вебхуком или потоком событий. Тяжёлые референсы — заранее через POST /v1/files, в запрос — file_….

Терминал
curl -i https://api.souz.ai/v1/images/generations \
  -H "Authorization: Bearer $SOUZ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Prefer: respond-async" \
  -H "Idempotency-Key: order-1842-cover" \
  -d '{"model": "nano-banana-pro", "prompt": "кот-космонавт на Луне"}'
HTTP
HTTP/1.1 202 Accepted
Location: /v1/jobs/job_1a2b3c4d5e6f
Retry-After: 5
JSON
{
  "id": "job_1a2b3c4d5e6f",
  "object": "job",
  "created": 1758801600,
  "created_at": "2026-09-25T12:00:00Z",
  "completed_at": null,
  "model": "nano-banana-pro",
  "modality": "image",
  "status": "queued",
  "routing": "balanced",
  "max_price_multiplier": null,
  "routing_options": null,
  "attempt_count": 0,
  "price": null,
  "currency": "RUB",
  "usage": null,
  "data": [],
  "error": null
}