Polling a Video Generation API in Python: Intervals, Timeouts, Stuck PENDING Tasks and Double Charges (2026)
Async video APIs hand you a task ID and leave the waiting to you. This guide walks through how cocodot's video API actually behaves while you poll, and what a Python poller should do about intervals, deadlines, timeouts, missing webhooks and batch limits, with a resumable asyncio example.
1. The contract your poller is written against
Three calls make up the whole flow. POST https://cocodot.co/api/ai/video/generations submits a job and returns its id, status, model and createdAt; the status at that point is PENDING. GET https://cocodot.co/api/ai/video/generations/{id} returns the current state of one task: PENDING while it is being generated, SUCCEEDED with a videoUrl when the clip is ready, or FAILED with an error string explaining why (the message text is in Chinese, so log it verbatim and translate when you read it). GET https://cocodot.co/api/ai/video/generations without an ID lists the 50 most recent tasks on your account, newest first, each with its id, model, status, resolution, videoUrl, createdAt, the first 300 characters of the prompt when the prompt was sent as text in content, and costUsd, the amount actually charged for that task. All three accept an API key in Authorization: Bearer cocodot-…, and also accept a signed-in session token. Which models exist and which resolutions, durations and modes each one accepts is published at GET https://cocodot.co/api/ai/video/models; validate against it in your own code so that invalid requests never leave your machine. The request body differs between the Seedance, Kling and Hailuo families, and that, along with every submission error, is covered in /hub/video-api-error-codes-fix. This page is only about the part after submission: waiting well. Identity verification (L1) is required before calling, and video generation cannot use the $0.5 trial credit.
2. How often to poll: the 10-second window
The instinct with any async API is that polling more often gets you the result sooner. On cocodot's video API it does not. Under the current implementation, if a task was last checked less than 10 seconds ago, the GET returns PENDING immediately instead of looking again. A loop that polls every second and a loop that polls every ten seconds therefore see SUCCEEDED at almost the same moment; the first one simply sends ten times the requests and gives a flaky network ten times the chances to throw an exception into your loop. A sensible default is to wait 15 seconds before the first check and then poll every 10 to 15 seconds. When you run many tasks in parallel, 20 to 30 seconds per task is fine and costs almost nothing in latency. You also do not need to poll to keep a task moving: the service checks every task still in progress on its own schedule, every few minutes, so a task finishes and is settled whether or not anyone is asking. How long generation takes depends on the model, the resolution and the duration, and we do not publish a fixed figure. Time a few of your own runs, keep the numbers, and set deadlines from what you observed rather than from a guess.
3. Stuck in PENDING: the status means 'not known yet', not 'running'
A naive loop says: keep polling until the status is no longer PENDING. That loop can run for a very long time, because PENDING covers more than one situation. The task may genuinely still be generating. It may have been checked within the last 10 seconds. The server's own status lookup may have failed this time, in which case the API answers PENDING rather than surfacing an error to you. Or the submission's outcome may still be unconfirmed. Your client cannot tell these apart from a single response, so it needs its own deadline, chosen by how long your application can afford to wait: thirty minutes for an interactive feature, a few hours for a batch job. When the deadline passes, the right move is to record the task ID as unresolved and exit, not to resubmit. A resubmission is a new task with a new hold on your balance, and if the original was merely slow you now have two. The service has its own backstops. A task whose submission outcome could not be confirmed within 2 hours is marked FAILED and its hold is released in full. An accepted task that has produced no result after 72 hours is marked FAILED in the same way. In both cases the error is the generic failure message, and that is the point at which a resubmission makes sense.
4. Client timeouts on submit: how one video gets paid for twice
This is where polling scripts lose money quietly. When cocodot receives a submission, it creates the task and places a conservative estimate of the charge on hold before it sends the job on for generation. Submissions from the same account are processed one at a time, so a single POST can take a while to return, especially when several of your workers submit at once. If your HTTP client gives up after ten seconds and your retry decorator fires, the first task may already exist with its estimate held, and the retry creates a second one. Three rules prevent this. Give the submit call a generous timeout, around 120 seconds. Never auto-retry a submit that timed out or lost its connection; instead, list recent tasks and look for one created in the last few minutes with the same model and prompt prefix, and if it is there, poll that ID. And do not expect parallel submission threads to go faster, because the account's submissions are queued anyway; they only make timeouts harder to interpret. One more case is worth knowing: if the outcome on the generation side is uncertain at submit time, the API still returns a task ID with status PENDING. Poll it like any other task; if acceptance is never confirmed, it is released after 2 hours as described above.
5. No webhooks and no cancel: what happens after your script exits
Some video platforms let you register a callback URL and push the result to you. cocodot's video API does not. A callback_url or webhook field in the request body is not rejected, but it is dropped before the job is sent, and no notification will ever arrive. Polling is the client-side way to retrieve status, which is why the deadline and ledger habits in this guide matter. There is also no endpoint for cancelling a task. Pressing Ctrl+C, redeploying a worker or letting a poll loop time out only stops your side from asking. The task keeps generating, is settled when it completes, and shows up in the task list with its costUsd. Settlement works from the estimate held at submission: the actual usage is charged and the difference is refunded or collected. A task that fails is not charged, and its hold is released in full. The practical consequence: a typo in the duration or resolution cannot be undone by killing the process, so validate parameters against the models endpoint before submitting, and run one or two low-resolution, short jobs end to end, including the download step, before launching a large batch.
6. Batches: three in flight, and a local ledger
An account can have at most three tasks generating at the same time. A fourth submission returns 400 with a message asking you to wait for one to finish, and no task is created and nothing is held. Each submission also checks your balance against its own estimate, and tasks already generating have their estimates held, so a large batch can hit an insufficient-balance error part way through; the message includes the estimate for that video. The robust pattern is a gate rather than a retry loop: hold a semaphore of three, acquire a slot before submitting, release it when the task reaches SUCCEEDED or FAILED. Retrying a 400 with sleep-and-resubmit appears to work until it meets the timeout case from the previous section, and then you cannot tell which of your attempts created a task. Alongside the gate, keep a ledger: append a line with the task ID, model and timestamp to a local file the moment a submit returns. When a worker restarts, it reads the ledger first, compares it with the server's task list, polls whatever is unfinished, and resubmits nothing that the server already knows about. Tasks that appear on the server but not in your ledger need investigation: they may come from another client or a submission whose response was lost. A list limited to the latest 50 tasks cannot prove an older task does not exist.
7. After SUCCEEDED: download, refresh and reconcile
A SUCCEEDED task carries a videoUrl pointing at the generated file. It is not your storage, and we do not promise how long the link stays valid, so download the file promptly and keep your own copy. Stream it to disk rather than reading the whole response into memory. If a download fails, GET the task again before retrying. Under the current implementation, Hailuo clips are served through signed links that expire, and a GET on a succeeded Hailuo task refreshes the link when needed; for the other model families the same link is returned again, so if it no longer opens, keep the task ID and contact support. For cost tracking, the estimate quoted in an insufficient-balance message is not what the task cost. The authoritative number is costUsd in the task list, written after settlement. A good record holds the task ID, model, resolution, duration, costUsd and the path of the saved file in one row; it lets you compute cost per clip and gives support everything needed to look into a specific job. Model pricing and billing units are compared in /hub/ai-video-api-price-compare, and the pricing page lists current rates.
8. A resumable asyncio poller
The script below applies each rule from this guide. A semaphore keeps three tasks in flight. Every submission response containing an ID is appended to a local ledger; an interrupted response can leave the ID unknown. Queued submissions stop on a submission error or an unresolved task, and you must reconcile before restarting. This gate does not coordinate other clients using the same account. Polling waits 15 seconds between checks and gives up at a deadline with an UNRESOLVED marker instead of resubmitting. Network errors during polling retry the same task ID. The download step fetches the task again first, so a Hailuo link is refreshed. And reconcile() is a read-only comparison of the ledger with the server's 50 most recent tasks, which is the first thing to run after a crash or a submit timeout. It uses httpx for async HTTP; swap the key after Bearer for your own cocodot API key, and take model names and allowed values from the models endpoint. Handling of specific 400, 401, 403, 429 and 503 responses, and of FAILED tasks, follows the table in /hub/video-api-error-codes-fix; this poller is an example for recording known task IDs and avoiding automatic resubmission; it cannot guarantee recovery of a response lost before the ID was recorded. Per-model request details are in /hub/seedance-2-5-api, /hub/kling-v3-api and /hub/hailuo-2-3-api.
import asyncio, json, time, pathlib
import httpx
API = "https://cocodot.co/api/ai/video/generations"
HEADERS = {"Authorization": "Bearer cocodot-..."}
LEDGER = pathlib.Path("video_ledger.jsonl") # one line per accepted task
SLOTS = asyncio.Semaphore(3) # a local gate, not an account-wide lock
STOP = asyncio.Event() # stop queued submissions on an unresolved outcome
def record(**row):
with LEDGER.open("a", encoding="utf-8") as f:
f.write(json.dumps(row) + "\n")
async def submit(client, body):
# Submissions from one account are handled one at a time, so allow a long timeout.
# On a timeout, do NOT resend: call reconcile() first.
r = await client.post(API, json=body, timeout=120)
if r.status_code >= 400:
raise RuntimeError(f"submit {r.status_code}: {r.text}")
task = r.json()
record(id=task["id"], model=body["model"], submitted=time.time())
return task["id"]
async def poll(client, task_id, every=15, give_up_after=3600):
deadline = time.monotonic() + give_up_after
while time.monotonic() < deadline:
await asyncio.sleep(every) # repeats inside 10 s return PENDING without a fresh check
try:
r = await client.get(f"{API}/{task_id}", timeout=90)
r.raise_for_status()
t = r.json()
except (httpx.HTTPError, ValueError):
continue # network blip: keep polling the SAME task
if t["status"] in ("SUCCEEDED", "FAILED"):
return t
return {"id": task_id, "status": "UNRESOLVED"} # check later; never resubmit from here
async def save(client, task_id, path):
r = await client.get(f"{API}/{task_id}", timeout=90)
r.raise_for_status()
t = r.json() # fetch the link right before downloading
async with client.stream("GET", t["videoUrl"], timeout=300) as r:
r.raise_for_status()
with open(path, "wb") as f:
async for chunk in r.aiter_bytes(1 << 20):
f.write(chunk)
async def job(client, i, body):
async with SLOTS:
if STOP.is_set():
return
try:
task_id = await submit(client, body)
result = await poll(client, task_id)
except Exception:
STOP.set() # reconcile before submitting any more jobs
raise
if result["status"] == "UNRESOLVED":
STOP.set() # the server task may still occupy a slot
if result["status"] == "SUCCEEDED":
await save(client, task_id, f"clip_{i}.mp4")
record(id=task_id, final=result["status"], error=result.get("error"))
async def reconcile(client):
"""After a crash or a submit timeout: compare the ledger with the server. Read only."""
r = await client.get(API, timeout=60)
r.raise_for_status()
recent = r.json() # newest 50 tasks; not a complete history
known = {json.loads(l)["id"] for l in LEDGER.read_text().splitlines()} if LEDGER.exists() else set()
for x in recent:
tag = "" if x["id"] in known else " <- not in local ledger"
print(x["id"], x["model"], x["status"], x["createdAt"], x.get("costUsd"), tag)
async def main(bodies):
async with httpx.AsyncClient(headers=HEADERS) as client:
await asyncio.gather(*(job(client, i, b) for i, b in enumerate(bodies)))
# asyncio.run(main([{"model": "seedance-2.5", "content": [{"type": "text", "text": "a harbour at dawn, slow push-in"}], "resolution": "720p", "duration": 5}]))What a polling client runs into, and how cocodot's video API currently responds
| Situation | What the API does | What your code should do |
|---|---|---|
| Polling the same task again within 10 seconds | Returns PENDING without a fresh status check | Poll every 10 to 15 seconds; faster gains nothing |
| A status lookup fails on the server side | Returns PENDING rather than an error | Enforce your own deadline, then re-check later |
| Your submit request times out or the connection drops | The task may already exist with its estimate held | List recent tasks; absence alone does not prove submission failed |
| Request body includes callback_url or a webhook field | Ignored; no callback is sent | Poll |
| You want to stop a task | No cancel endpoint; stopping your script does not stop generation | Validate parameters before submitting |
| A fourth task while three are generating | 400, no task created, nothing charged | Gate submissions with a limit of 3 |
| Submission outcome never confirmed within 2 hours | Task becomes FAILED and the hold is released in full | Read the error text before resubmitting |
| Accepted task with no result after 72 hours | Task becomes FAILED and the hold is released in full | Same as above |
| Expired Hailuo download link | GET on the succeeded Hailuo task fetches a new link | GET the task right before each download |