Поллинг video API: 202 за 0.8 с, видео — 83–253 секунды
POST отдаёт 202 за 0.82 с, а дальше восемь одинаковых задач по 5 секунд расходятся втрое: 83.3 против 253.2. Отменить запущенную нельзя, ссылка живёт сутки.
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.ai/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.

Один ролик 4 секунды в 480p от начала до конца. Таблица из восьми задач ниже — отдельный набор пятисекундных роликов, так что 105.9 секунды здесь читайте как ещё одну точку, а не как строку той таблицы.
Сколько на самом деле длится задача
От 83 до 253 секунд на одном и том же запросе. Восемь задач, все — ролики 5 секунд в 480p на bytedance/seedance-2.0-mini, один ключ, один день:
| № | Что это было | Секунд до финала |
|---|---|---|
| 1 | text-to-video, 16:9 | 83.3 |
| 2 | text-to-video, 9:16, одна из трёх отправленных вместе | 85.1 |
| 3 | два референсных изображения | 95.6 |
| 4 | первый и последний кадр, ratio задан | 103.9 |
| 5 | первый и последний кадр, без ratio | 105.3 |
| 6 | text-to-video, 9:16, одна из трёх отправленных вместе | 126.7 |
| 7 | text-to-video, 16:9 | 139.9 |
| 8 | text-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.


