Джобы и результаты

.md

Жизненный цикл async-задач, polling, подписанные ссылки и честная оговорка — доступность и фактическое удаление результатов пока не гарантированы.

Картинки и видео создаются асинхронно: POST .../generations сразу возвращает джобу, а результат появляется в ней позже.

Что возвращает создание

Ответ Когда
202 + джоба queued Новая джоба принята в работу
200 + джоба Повтор с тем же Idempotency-Key, а существующая джоба не failed — вернём её как есть, в любом статусе (queued, in_progress, completed)
400 + джоба failed Либо повтор ключа для уже упавшей джобы, либо запрос отклонён сразу (модель отключена, неподдержанное поле, не хватило кредитов)
400 + короткий конверт invalid_request Ошибка на границе: модели не существует, тело кривое, плохой референс

Разбор всех случаев и что при этом происходит с деньгами — в Ошибках.

Статусы

queued → in_progress → completed
                      ↘ failed
Статус Значение
queued Принята, ждёт обработки
in_progress Генерируется у провайдера
completed Готово — ссылка в data[0].url
failed Не получилось — причина в error.code; деньги не списаны: резерв возвращён либо не создавался

Опрос

GET /v1/jobs/{id}
curl https://api.souz.ai/v1/jobs/job_a1b2c3 \
  -H "Authorization: Bearer sk-souz-..."

Ответ — всегда полный объект джобы:

{
  "id": "job_a1b2c3",
  "object": "image",
  "model": "souz/nano-banana-pro",
  "status": "completed",
  "price": 121500,
  "created_at": 1781250000,
  "parameters": {
    "schema": "souz.parameters/v1",
    "requested": { "resolution": "2K" },
    "effective": { "aspect_ratio": "1:1", "resolution": "2K" },
    "degradations": []
  },
  "data": [{ "url": "https://api.souz.ai/v1/files/res_....png?exp=...&sig=..." }]
}

price — целые микро-USD, как все денежные поля API (121 500 µUSD ≈ 12.15 кредита). parameters есть у новых normalized job и одинаково выглядит в create/replay, polling и webhook. requested содержит только source-free канонический intent; URL/data URL, digest, object key и provider locator в него не попадают. У старых legacy job поле отсутствует. effective остаётся null, пока attempt-owned outcome не зафиксирован вместе с durable send intent; при этом degradations пуст. После этого поля проецируются из immutable snapshot текущей/terminal попытки. При allow_degradation:true нетерминальный price остаётся maximum reserve, а terminal success показывает фактический capture. Если резерв был создан и затем возвращён, terminal failed показывает 0; pre-reserve capability/validation failure также имеет price: 0. Отдельное исключение — insufficient_credits: резерва не было, а price сохраняет рассчитанную maximum charge.

При failed вместо data приходит error:

{ "status": "failed", "error": { "code": "content_blocked", "message": "content_blocked", "type": "content_blocked" } }

Для capability_mismatch тот же error дополнен details.schema = "souz.capability-mismatch/v1", отсортированным blocking[] и available_degradations[]. Это provider-opaque описание: имена/модели провайдера, стоимость, попытки, секреты и locator-ы туда не попадают.

Рекомендуемая частота опроса: картинки — раз в 2–5 сек, видео — раз в 5–10 сек. Вебхуки добавляют push-уведомление, но до RSA-109 не заменяют polling fallback.

Результат апстрима не публикуется вслепую. Перед сохранением Souz требует ровно один ожидаемый артефакт, ограничивает объём ответа и проверяет URL, формат и полную декодируемость изображения. Если уже оплаченный результат небезопасен или неоднозначен, джоба остаётся in_progress, а не переключается на другого провайдера и не возвращает резерв без доказательства. Сырые URL и детали ошибок провайдера клиенту не выдаются. Видео в первом сертифицированном срезе выключено: запрос получает model_unavailable до резерва кредитов и вызова провайдера.

Файлы результатов

data[0].urlподписанная ссылка (?exp=...&sig=...): её можно открывать в браузере и отдавать куда угодно без API-ключа, пока не истекла подпись. Если подпись истекла — запроси джобу ещё раз и получишь свежую ссылку.

Retention пока не подтверждён. Не гарантируются ни минимальный срок доступности, ни срок фактического удаления объекта: планируй скачивание сразу после completed, но не считай недоступную ссылку доказательством удаления.

Что означают ответы по ссылке на файл и что делать:

Ответ Что случилось Что делать
403 forbidden (наш JSON) Подпись ссылки невалидна или истекла; сам файл может быть на месте Шаг 1 ниже
403 / 404 без нашего тела Непрозрачный ответ файлового хоста при прямой раздаче: это может быть отсутствие объекта, а может — права доступа, конфигурация или временный сбой хранилища. Мы такой ответ не формируем и не проверяли объект перед редиректом Шаги 1–3 ниже
404 not_found (наш JSON) Локальная ветка сейчас сводит любую ошибку чтения (ENOENT, права, I/O, конфигурацию) к этому ответу Не считать доказательством удаления: свежая ссылка → повтор с паузой → поддержка
410 file_expired (наш JSON) Наш структурированный ответ: мы отдавали файл через свой прокси и объекта в хранилище нет Отсутствие подтверждено; если результат нужен — генерировать заново
502 storage_unavailable Временная проблема хранилища Повторить с паузой — это не удаление

Порядок восстановления для неоднозначных 403/404 (не начинай с перегенерации — она платная):

  1. Обнови ссылку. Запроси GET /v1/jobs/{id} — там свежая подпись. Часто этого достаточно.
  2. Повтори с нарастающей паузой. Временные сбои хранилища и CDN выглядят так же, как отсутствие файла, но проходят сами.
  3. Не помогло — напиши в поддержку. Устойчивые 403/404 от файлового хоста могут быть проблемой конфигурации на нашей стороне; перегенерация её не вылечит.

Генерировать заново имеет смысл после структурированного 410 — или когда отсутствие подтверждено отдельно. Локальный структурированный 404 пока тоже неоднозначен (RSA-92). И учти: если причина была в сбое хранилища, новая генерация может упереться в него же.

⚠️ В тексте message у 410 пока встречается упоминание «~24 часа» — это устаревшая строка в коде, а не обещание. Никакого гарантированного срока хранения сейчас нет; ориентируйся на error.code.

Правило простое: скачивай результат сразу после completed и храни у себя.

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

Передай непустой Idempotency-Key: <твой-уникальный-id> (до 255 символов) при создании image/video-джобы. Souz claim'ит ключ в пределах аккаунта до проверки текущего каталога, callback DNS, загрузки референсов, резерва и enqueue. Поэтому повтор того же смыслового запроса вернёт исходную job даже если source URL уже протух или модель позже отключили; исходные URL заново не читаются. Модель и webhook_secret берутся из исходного admission, а не из retry.

Тот же ключ с другим смысловым payload получает 409 idempotency_conflict. Если первый одинаковый запрос ещё готовит job, конкурентный retry может получить retryable 409 idempotency_in_progress и Retry-After; повтори его позже с тем же телом — после bind он вернёт исходную job. Порядок JSON-полей значения не имеет, порядок элементов массивов имеет; omitted optional и null там, где API трактует их одинаково, нормализуются одинаково. Без заголовка каждый вызов остаётся новой генерацией.

curl https://api.souz.ai/v1/images/generations \
  -H "Authorization: Bearer sk-souz-..." \
  -H "Idempotency-Key: order-42-avatar" \
  -H "Content-Type: application/json" \
  -d '{ "model": "souz/nano-banana-2", "prompt": "..." }'

Дедлайн

Provider-processing deadline с запасом — 15 минут для картинки и 90 минут для видео. По его истечении джоба остаётся публично in_progress, пока система разбирает неоднозначный результат. Примерно через 10 минут grace начинается proof-only сверка, но это не таймер возврата: подтверждённый результат даёт completed, доказанное отсутствие работы — failed: model_unavailable и возврат резерва, а pending/неработающий lookup/отсутствие механизма оставляют джобу in_progress с удержанным резервом. Worker повторяет сверку и эскалирует размер и возраст backlog оператору.

Тот же режим действует, когда связь с провайдером оборвалась после отправки запроса (таймаут, разрыв, 408 или любой 5xx от шлюза): раз мы не знаем, взял он работу или нет, джоба не отдаётся другому провайдеру и её резерв не возвращается сразу. Она остаётся in_progress, пока сверка не выяснит судьбу: подтверждённый результат даёт completed с обычным списанием, а failed + возврат появляется только после доказательства, что работа не была принята или закончилась без результата. Неподтверждённая судьба остаётся in_progress; оператор видит такой backlog.

Legacy-исключение из «джоба всегда придёт к терминальному статусу»: если обнаружена старая или противоречивая пара «списание есть, доставляемого результата нет», джоба замораживается и остаётся in_progress до разбора. Объявлять успех нечем, а реальный расход не отменяется выдуманной проводкой. Для новых async-job manifest результата, списание и terminal status коммитятся одной транзакцией.

⚠️ Намерение отправки коммитится до provider call, поэтому рестарт не превращает одну логическую попытку во второй submit. Принятый async-результат восстанавливается по тому же provider task и immutable manifest; sync body-only вариант без такого механизма не принимается в production-контур. Подробности про деньги — в Кредитах.