cocodot
← Back to guides
Local card declined for overseas AI? cocodot: one card + one key
视频生成 APIUpdated 2026-11

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.

TL;DR: Submit once, write the task ID down immediately, poll every 10 to 15 seconds, stop at a deadline you choose, and download the file as soon as the task succeeds. cocodot's video API is asynchronous: POST https://cocodot.co/api/ai/video/generations returns a task ID, and GET https://cocodot.co/api/ai/video/generations/{id} returns one of three states, PENDING, SUCCEEDED or FAILED. Four behaviours shape the client. Repeat checks on the same task within 10 seconds return PENDING without a fresh look, so tighter polling only adds requests. A callback or webhook field in the request is ignored, so poll to retrieve the result. There is no cancel endpoint, so a stopped script does not stop a task that is already generating, and it is settled when it finishes. And a client-side timeout on submit does not mean nothing was created: check the task list before you resend, or one video can be paid for twice. Each account can have three tasks generating at once.

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.

Resumable video task poller with a 3-task gate and a local ledger
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

SituationWhat the API doesWhat your code should do
Polling the same task again within 10 secondsReturns PENDING without a fresh status checkPoll every 10 to 15 seconds; faster gains nothing
A status lookup fails on the server sideReturns PENDING rather than an errorEnforce your own deadline, then re-check later
Your submit request times out or the connection dropsThe task may already exist with its estimate heldList recent tasks; absence alone does not prove submission failed
Request body includes callback_url or a webhook fieldIgnored; no callback is sentPoll
You want to stop a taskNo cancel endpoint; stopping your script does not stop generationValidate parameters before submitting
A fourth task while three are generating400, no task created, nothing chargedGate submissions with a limit of 3
Submission outcome never confirmed within 2 hoursTask becomes FAILED and the hold is released in fullRead the error text before resubmitting
Accepted task with no result after 72 hoursTask becomes FAILED and the hold is released in fullSame as above
Expired Hailuo download linkGET on the succeeded Hailuo task fetches a new linkGET the task right before each download

FAQ

My video generation task is stuck in pending. Should I resubmit it?

No. PENDING can mean the task is still generating or that the status could not be looked up this time. Keep polling the same ID every 10 to 15 seconds, stop at your own deadline, and check again later. Tasks never confirmed within 2 hours, and accepted tasks with no result after 72 hours, are marked FAILED with the hold released in full.

How often should I poll a video generation API?

Every 10 to 15 seconds after an initial 15-second wait. On cocodot, repeat checks of the same task within 10 seconds return PENDING without a fresh look, so polling faster does not get the video sooner.

Can a client timeout charge me twice for one video?

Yes, if your code retries the submit automatically. The task may already exist with its estimate held. List recent tasks with GET /api/ai/video/generations, poll the one you find, and if no matching task is visible, keep the outcome unresolved and contact support before resubmitting. Use a submit timeout of around 120 seconds.

Does the cocodot video API support webhooks or callbacks?

Not at present. A callback or webhook field in the request is ignored and no notification is sent, so poll for the result.

Can I cancel a video generation task?

No. There is no cancel endpoint, and stopping your script does not stop generation; the task is settled when it completes. Check parameters before submitting.

Why did my Hailuo video link stop working?

Hailuo links are signed and expire. GET the succeeded task again to fetch a fresh link, then download right away and keep your own copy.

About cocodot

cocodot is a payment and AI access service for developers and cross-border teams in mainland China. It provides US-BIN virtual cards issued by a licensed institution — used to pay for overseas subscriptions and ad accounts — and an OpenAI-compatible AI API gateway for calling Claude, GPT and Gemini from within mainland China. Both share one wallet, funded by Alipay / WeChat Pay (RMB), USDT / USDC and accounted in USD. Card: $9.9 to open, 3% to load, $1 per active card per month; spending: $0.60 settlement fee on purchases under $20; a corresponding fee applies when the issuer charges one.

Service scope, pricing and limits →