Поллинг video API: 202 за 0.8 с, видео — 83–253 секунды

POST отдаёт 202 за 0.82 с, а дальше восемь одинаковых задач по 5 секунд расходятся втрое: 83.3 против 253.2. Отменить запущенную нельзя, ссылка живёт сутки.

Поллинг video API: 202 за 0.8 с, видео — 83–253 секунды

202 приходит меньше чем за секунду, а видео готовится ещё две–четыре минуты, и сколько именно — из запроса не видно. Всё сложное в video API живёт в промежутке между этими двумя фактами.

Отправка:     POST /v1/videos -> 202 за 0.82 с
Тело ответа:  {id, status: "queued", polling_url}   три поля, больше ничего
Ожидание 5 с: 83.3–253.2 с на восьми одинаковых задачах, медиана 104.6
Лимит опроса: 5 rps на ключ, всплеск 20, сверху 429 + Retry-After: 1
Финальные:    completed | failed | cancelled | expired   все четыре, иначе цикл вечный
Отмена:       400 cancel_failed, как только апстрим начал считать
Ссылка:       unsigned_urls подписан на 24 часа. mirror_urls у Seedance нет.
Измерено:     2026-08-24, 13 задач через POST /v1/videos

Обновлено 2026-08-24. Тайминги сняты за один день на одном маршруте и у вас не повторятся; переносимая часть — форма распределения, а не конкретные секунды.

Что на самом деле возвращает POST /v1/videos

202 с тремя полями. Ни видео, ни процентов, ни оценки:

{
  "id": "5e6f69b1-8ffe-430c-a687-8241366a90f5",
  "status": "queued",
  "polling_url": "https://api.ofox.io/v1/videos/5e6f69b1-8ffe-430c-a687-8241366a90f5"
}

Вызов занял 0.82 секунды. polling_url — это удобство: тот же GET /v1/videos/{id}, который вы собрали бы сами. Берите поле, а не склеивайте строку: сегодня id выглядит как UUID, но обещания, что так останется, нет.

Пока задача в работе, тело статуса нарочно скудное:

{"id": "...", "status": "in_progress", "model": "bytedance/seedance-2.0-mini",
 "prompt": "...", "created_at": 1787567608, "updated_at": 1787567608}

Поля progress, из которого можно нарисовать полосу, нет. Если интерфейсу она нужна, это будет догадка по прошедшему времени относительно исторической медианы — и после следующего раздела станет понятно, почему эта полоса обязана честно признаваться, что она догадка.

Когда задача завершается, появляются два ключа: unsigned_urls и usage.

Терминал: POST к видеоэндпоинту ofox возвращает HTTP 202 за 0.797 секунды, цикл опроса печатает queued на 1.2 секунде, in_progress на 15.2 и completed на 105.9, в завершённом теле usage с video_cost 0.08, затем 404 на неизвестный id и 400 на callback-адрес в приватной сети

Один ролик 4 секунды в 480p от начала до конца. Таблица из восьми задач ниже — отдельный набор пятисекундных роликов, так что 105.9 секунды здесь читайте как ещё одну точку, а не как строку той таблицы.

Сколько на самом деле длится задача

От 83 до 253 секунд на одном и том же запросе. Восемь задач, все — ролики 5 секунд в 480p на bytedance/seedance-2.0-mini, один ключ, один день:

Что это былоСекунд до финала
1text-to-video, 16:983.3
2text-to-video, 9:16, одна из трёх отправленных вместе85.1
3два референсных изображения95.6
4первый и последний кадр, ratio задан103.9
5первый и последний кадр, без ratio105.3
6text-to-video, 9:16, одна из трёх отправленных вместе126.7
7text-to-video, 16:9139.9
8text-to-video, 9:16, одна из трёх отправленных вместе253.2

Медиана 104.6 секунды. Самая медленная к самой быстрой — 3.0 раза. Три самые медленные и три самые быстрые ничем не отличаются в запросе: задачи 2, 6 и 8 — одна модель, одна длительность, одно разрешение и одно соотношение сторон, отправлены в одну и ту же секунду, а закончились через 85, 127 и 253 секунды.

Отсюда два практических вывода.

Ставьте таймаут по хвосту. Клиентский таймаут в 120 секунд убил бы задачу 8, пока она ещё генерировалась и тарифицировалась на стороне провайдера. Мы держим жёсткий потолок в 900 секунд и логируем всё, что перевалило за 300.

Не обещайте ETA. Очередь роликов заканчивается тогда, когда заканчивается. Если продукт показывает обратный отсчёт, стройте его на скользящей медиане собственных недавних задач и позвольте ему выйти за рамки, вместо того чтобы врать.

Неожиданный момент: более крупная модель не обязательно медленнее. В тот же день задача 5 секунд в 480p на bytedance/seedance-2.5 завершилась за 53.1 секунды — быстрее любого запуска Mini выше, — а её вариант с первым и последним кадром занял 212.9 секунды. Режим двигает число сильнее, чем класс модели. Остальная часть этого сравнения — в разборе первого и последнего кадра.

Как часто опрашивать статус

Каждые 2–5 секунд. Эндпоинт статуса документирует лимит 5 запросов в секунду на ключ со всплеском до 20; сверху приходит 429 rate_limited с заголовком Retry-After: 1. Создание и отмена не в счёт. Документация также просит не чаще раза в секунду, так что между «вежливо» и «придушили» остаётся комфортный коридор.

На интервалах 2–3 секунды во всех прогонах этой статьи ни один опрос не вернул ошибку.

import time, requests

H = {"Authorization": "Bearer YOUR_OFOX_API_KEY"}
TERMINAL = {"completed", "failed", "cancelled", "expired"}

def wait(job, timeout=900, interval=3):
    t0 = time.time()
    while True:
        s = requests.get(job["polling_url"], headers=H).json()
        if s["status"] in TERMINAL:
            return s
        if time.time() - t0 > timeout:
            raise TimeoutError(f"{job['id']} still {s['status']} after {timeout}s")
        time.sleep(interval)

Три вещи, которые этот цикл делает правильно, а большинство опубликованных примеров — нет. Он выходит на всех четырёх финальных статусах. У него есть потолок, поэтому зависшая задача не займёт воркер навсегда. И он читает polling_url из ответа на отправку, а не собирает его заново.

Какие статусы финальные

Четыре из семи. Документированная машина состояний:

СтатусФинальныйЗначение
pendingПринято, ещё не отправлено провайдеру
queuedОтправлено провайдеру, ждёт в очереди
in_progressГенерируется
completedСсылки на видео доступны
failedОшибка, включая таймаут с error.code: "expired"
cancelledОтменено
expiredИстекло

Статуса processing не существует. Циклы, скопированные из SDK других вендоров, часто ждут именно его — и ждут вечно.

pending мы не наблюдали ни разу. Длительность queued плавает: в большинстве прогонов первый опрос через 0.3 секунды после отправки уже показывал in_progress, а задача со скриншота выше просидела в queued 14 секунд. Это не баг, это глубина очереди. Обрабатывайте pending всё равно: состояние, которого вы не видели в тестах, обязательно появится на той неделе, когда вырастет нагрузка.

Отдельно про таймаут. Генерация, которая не уложилась, приходит как failed с error.code: "expired", а не отдельным статусом expired. Одно и то же слово живёт в двух местах с разными значениями, поэтому ветвитесь по error.code, который справочник ошибок называет стабильным полем, а не по тексту сообщения.

Можно ли отменить запущенную задачу

Обычно нет, и этот отказ — честный ответ. Мы отправили задачу, подождали шесть секунд и послали DELETE /v1/videos/{id}:

{"error": {"code": "cancel_failed",
  "message": "upstream cancel failed: cancel failed: status 409, body:
    {\"error\":{\"code\":\"InvalidAction.RunningTaskDeletion\",
     \"message\":\"Cannot delete task `cgt-...` because it is currently running.\"}}"}}

Две вещи, которые стоит знать до того, как рисовать кнопку «Стоп».

Документация описывает cancel_failed как код для задачи, уже находящейся в финальном состоянии, а cancel_not_supported — для провайдеров, которые не умеют прерывать. Мы попали ни в то, ни в другое: работающая задача, чей провайдер отказал в удалении, пришла как cancel_failed с завёрнутым 409. Если ветвитесь по этому коду, допускайте оба смысла.

И в тот же момент GET всё ещё сообщал статус queued. То есть queued в теле статуса не означает, что задачу можно отменить: апстрим уже начал. Нет статуса, который надёжно скажет, что отмена сработает. Пробуйте, проверяйте 204, а получив 400, считайте, что ролик вы уже оплатили.

Вывод непопулярный, но простой: точка невозврата — это отправка. Проверяйте промпт, референсы и длительность до POST, потому что в момент получения id ролик, скорее всего, уже куплен.

Почему ссылка на видео перестаёт работать

Потому что unsigned_urls — подписанный адрес апстрима со сроком жизни. В нашем случае в query стоял X-Tos-Expires=86400, то есть 24 часа, и документация описывает это поле как временное, истекающее примерно за сутки.

Есть второе поле, mirror_urls, описанное как постоянное и предпочтительное, — оно появляется, если у провайдера включено зеркалирование через CDN. Во всех ответах Seedance, которые мы забрали в этот день, тело завершённой задачи содержало ровно эти ключи:

created_at, id, model, prompt, status, unsigned_urls, updated_at, usage

Никакого mirror_urls. То есть для этого семейства моделей «предпочитайте mirror_urls» превращается в «ссылка одна, и она протухнет». Скачивайте байты в том же воркере, который увидел completed, и кладите в собственное хранилище. Не сохраняйте URL в базу, считая задачу закрытой: именно так контент-пайплайн через сутки получает таблицу мёртвых ссылок.

Заодно прочитайте usage:

"usage": {"video_seconds": 5, "video_cost": "0.1000000000"}

video_cost — строка, а не число, и это сделано намеренно: документация описывает её как строку с фиксированной точкой на 10 знаков, чтобы не терять точность. Парсите как decimal, а не как float, и считайте по video_seconds, а не по длительности из запроса: запрос на 5 секунд возвращает файл на 5.04 секунды.

Как написать один цикл поллинга под все видеомодели

Цикл выше — строк пятнадцать, и писать его аккуратно один раз стоит потому, что каждый вендор видео изобрёл свою версию. Один возвращает объект задачи и отдельный эндпоинт результата, другой требует опрашивать URL из заголовка, третий назвал статусы иначе, четвёртый берёт деньги за отмену, которая, как вам казалось, сработала. Поддержать три видеомодели нативно — это три цикла, три набора финальных состояний и три особенности тарификации, и ни одна из этих работ не интересна.

Все ролики в этой статье вернулись через один и тот же POST /v1/videos и один и тот же GET /v1/videos/{id} независимо от модели — поэтому таблица ожиданий и может поставить Seedance 2.5 и 2.0 Mini в одну колонку. Ради этой нормализации видеошлюз и существует; мы работаем на видеоэндпоинте ofox, а проверять у любого шлюза стоит одно: при смене поля model перечисление статусов и форма usage остаются прежними. Если нет — циклов у вас по-прежнему три, просто они спрятаны за одним хостнеймом.

Про выбор модели, которая пойдёт в этот цикл: как выбирать video API под задачу, а посекундные цены разобраны в сравнении fal, WaveSpeed и AtlasCloud.

Стоит ли перейти на webhook

Если есть публичный HTTPS-эндпоинт — да. Передайте callback_url при создании, и на каждую задачу придёт один POST в момент её завершения: полный объект задачи, заголовок X-Ofox-Signature с HMAC-SHA256 и X-Ofox-Idempotency-Key. События один в один соответствуют финальным состояниям.

Проверка происходит при отправке, а не при доставке. Мы указали http-адрес в приватной сети и немедленно получили:

400 invalid_callback_url
"callback_url must be a public HTTPS URL: ssrf blocked: target is private,
 reserved, or scheme not allowed: scheme must be https"

Знать это полезно на этапе разработки, потому что самое естественное — попробовать localhost или адрес в локальной сети — и есть ровно то, что отсекает защита от SSRF. Используйте туннель с настоящим HTTPS-именем либо опрашивайте статус в разработке и переключайтесь на webhook в проде. Комбинация тоже нормальна: зарегистрируйте webhook и оставьте медленный сборщик, который добирает всё, что висит открытым дольше десяти минут. Неполученный webhook и незавершённая задача с вашей стороны выглядят одинаково.

Источники

Часто задаваемые вопросы

Что возвращает POST /v1/videos?
HTTP 202 и ровно три поля: id, status со значением queued и polling_url. Наш запрос на отправку занял 0.82 секунды. Ни видео, ни процента готовности, ни оценки времени в ответе нет — в этом и смысл 202: работу приняли, но не сделали.
Сколько времени занимает генерация видео?
Дольше, чем кажется, и предсказать нельзя. Восемь одинаковых задач на 5 секунд в 480p на Seedance 2.0 Mini, один и тот же день и один ключ, завершились в диапазоне от 83.3 до 253.2 секунды — разброс втрое при медиане 104.6. Таймаут надо считать по хвосту, а не по медиане.
Как часто опрашивать статус?
Раз в 2–5 секунд достаточно. У эндпоинта статуса лимит 5 запросов в секунду на ключ с всплеском до 20, при превышении приходит 429 rate_limited с заголовком Retry-After: 1. Документация просит не чаще одного раза в секунду. Создание и отмена под этот лимит не подпадают.
Какие статусы финальные?
Четыре: completed, failed, cancelled и expired. Всего в машине состояний семь, нефинальные — pending, queued и in_progress. Цикл, который выходит только по completed и failed, будет крутиться вечно на отменённой или истёкшей задаче.
Можно ли отменить уже запущенную задачу?
Чаще всего нет. DELETE по задаче, которая шла шесть секунд, вернул 400 cancel_failed с завёрнутым внутрь ответом 409 от провайдера: задачу нельзя удалить, потому что она выполняется. Возможность отмены зависит от того, поддерживает ли прерывание апстрим, и шлюз не изображает локальную отмену, пока апстрим продолжает генерировать и тарифицировать.
Почему ссылка на готовое видео перестала работать?
Потому что unsigned_urls — временный подписанный адрес апстрима. В нашем случае в query стоял X-Tos-Expires=86400, то есть 24 часа с момента подписи. Скачивайте файл или используйте mirror_urls, если у провайдера включено зеркалирование через CDN. В ответах Seedance, которые мы получали, поля mirror_urls не было вовсе.
Тарифицируются ли неудачные задачи?
Объект usage по документации появляется только у завершённых задач, а у неудачных мы получали usage null. Отдельно отметим: таймаут приходит не отдельным статусом, а как status failed с error.code, равным expired.
Стоит ли перейти на webhook вместо поллинга?
Если у вас есть публичный HTTPS-эндпоинт — да. Передайте callback_url при создании, и на финальном состоянии придёт один POST с полным объектом задачи, подписью HMAC-SHA256 и заголовком идемпотентности. URL проверяется в момент создания: наш http-адрес в приватной сети был отклонён сразу, с 400 invalid_callback_url.