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

Всё, кроме ответа чата, — **задача** с одним объектом (схема `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` в запросе: [вебхук](https://souz.ai/docs/webhooks.md) по завершении задачи.

```ts tab="TypeScript"
// 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));
  }
}
```

```python tab="Python"
import json, os, time, requests

def job_events(job_id: str):
    url = f"https://api.souz.ai/v1/jobs/{job_id}/events"
    headers = {"Authorization": f"Bearer {os.environ['SOUZ_API_KEY']}"}
    while True:  # поток закрылся без [DONE] — переподключаемся
        try:
            with requests.get(url, headers=headers, stream=True, timeout=(10, 60)) as res:
                res.raise_for_status()
                for line in res.iter_lines(decode_unicode=True):
                    if not line or not line.startswith("data: "):
                        continue
                    payload = line[6:]
                    if payload == "[DONE]":
                        return
                    yield json.loads(payload)
        except (requests.ConnectionError, requests.Timeout):
            pass  # обрыв — переподключиться
        time.sleep(1)
```

## Повтор без второй задачи — `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`](https://souz.ai/docs/files.md),
в запрос — `file_…`.

```bash
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
```

<!-- openapi: #/components/schemas/Job -->
```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
}
```

---

Оглавление документации: https://souz.ai/llms.txt. Всё руководство одним файлом: https://souz.ai/llms-full.txt. Справочник методов: https://souz.ai/docs/api-reference.md.
