0

Designing around a video API that forgets: two 24-hour clocks and a state you must special-case

Most asynchronous job APIs are built on an assumption you never have to think about: the job record outlives your interest in it. You submit, you poll, you fetch the result, and if your worker crashes for two days the record is still sitting there when it comes back.

Alibaba's Wan 3.0 video API does not make that assumption, and the difference shows up as a class of bug that is very hard to reproduce locally — because locally, you always come back within a minute.

There are two independent 24-hour clocks, and a task state that exists solely to tell you one of them ran out. If your integration does not model both, it will work perfectly in development and lose files in production.

All values below come from Alibaba Cloud's Model Studio API reference for wan3.0-video, checked 2026-08-27.

The state machine

PENDING → RUNNING → SUCCEEDED | FAILED

That is the happy path, and it is what every quickstart shows. The full enum has six members:

PENDING · RUNNING · SUCCEEDED · FAILED · CANCELED · UNKNOWN

Two of those need commentary.

CANCELED is unreachable. The published API reference has no cancel endpoint. The state is in the enum; there is no documented transition into it. Practically this means a submitted job is a committed spend — you cannot build a "stop" button, only a "don't start" guard. For a thirty-second 1080P render at Alibaba's published rate, that commitment is $6.00 at the moment of submission.

UNKNOWN is not a failure. This is the one that matters.

Clock one: the task ID expires in 24 hours

A task_id is queryable for 24 hours. Poll it after that and you get task_status: "UNKNOWN".

Read that literally: the service is not telling you the job failed. It is telling you it no longer has a record of what you are asking about. The job may well have succeeded. Nobody was there to collect it.

This produces two separate bugs depending on how you map the state.

If you map UNKNOWN → PENDING — which is the natural thing to do, because "unknown" sounds like "not yet known" — your client polls forever. Every long-running spinner in this category that anyone has ever complained about is, with high probability, this mapping.

If you map UNKNOWN → FAILED, your dashboards are wrong and your refund logic is wrong. A job that returned FAILED genuinely failed and, on every provider whose terms are readable, is not billed. A job that returned UNKNOWN might have produced a perfect file that you threw away. Those are not the same event and they should not share a row in your metrics.

The honest third state is expired:

TERMINAL = {"SUCCEEDED", "FAILED"}

def classify(status, submitted_at, now):
    if status in TERMINAL:
        return status.lower()
    if status == "UNKNOWN":
        # The receipt aged out. Not a model failure.
        return "expired"
    if now - submitted_at > timedelta(hours=24):
        return "expired"          # defensive: we stopped polling in time
    return "in_flight"

Tell the user "this job expired" rather than "this job failed," because the causes are different and so are the remedies. Failed means try again. Expired means your poller was down, and trying again costs money that the first attempt may already have spent.

Clock two: the output URL expires in 24 hours

This is the expensive one.

{
  "output": {
    "task_status": "SUCCEEDED",
    "video_url": "https://dashscope-result-bj.oss-…/video.mp4"
  }
}

That URL is valid for 24 hours from completion. The file was generated. The money was spent. If nothing downloaded it, it is gone, and there is no re-issue endpoint — regenerating means paying again, and with a random seed you will not get the same clip anyway.

So the single most important line in this integration is not the submit call and not the poller. It is the copy:

async def on_success(task):
    url = task["output"]["video_url"]
    # Do this before anything else. Before the webhook, before the DB write,
    # before the metrics. Everything else can be retried; this cannot.
    key = await storage.put_from_url(url, f"clips/{task['output']['task_id']}.mp4")
    await db.mark_delivered(task, key, usage=task["usage"])

Note the ordering. It is tempting to write the database row first because that is what you do everywhere else. Here, the durable-storage copy is the operation with a deadline and the database row is the one that can wait.

Reconcile against usage, not against your request

The response carries a usage object, and it is not decorative:

"usage": {
  "video_count": 1,
  "duration": 5.0,
  "input_video_duration": 0.0,
  "output_video_duration": 5.0,
  "fps": 30,
  "SR": 720,
  "ratio": "16:9"
}

Two reasons to bill from this object rather than from the parameters you sent.

First, duration accepts -1, meaning "model decides." You cannot know the length in advance, so output_video_duration is the only ground truth.

Second, input_video_duration is a separate field because reference video is billed. The formula that several gateway docs state identically is billable = input video seconds + output seconds, rounded up. A 4.5-second reference clip with a 5-second output at 720P bills 10 seconds, not 5. Reference images, audio, documents and links add nothing — it is specifically video. The existence of two separate duration fields in the response is the strongest evidence that this is how it works.

Capacity: the real limit is concurrency, not rate

Limit Value
Concurrent tasks 2
Async queue 50
Submissions 300 RPM
Query RPS 20
Typical job 1–5 minutes
Suggested poll interval 15 s

The submission rate ceiling is high enough that you will essentially never reach it. Concurrency of 2 is the constraint that will shape your product. Offer a user four variants of a shot and you are running two and queueing two; at one to five minutes each, that is a four-to-twenty-minute wait presented as a single action.

Design implications, in rough order of how much grief they save:

  1. Poll on a schedule (15 s), not in a tight loop. Query RPS is 20, and burning it on a job that takes minutes is pointless.
  2. Show queue position rather than a percentage. A progress bar for a job with two slots and fifty waiting is a lie you will have to keep telling.
  3. Put the "are you sure" before submission, since there is no cancel.
  4. Persist the seed from the response. Without it, "the same clip but shorter" is not a request anyone can fulfil.

A minimal poller

async def wait(task_id, *, interval=15, deadline_hours=24):
    started = utcnow()
    while True:
        r = await get(f"/api/v1/tasks/{task_id}")
        status = r["output"]["task_status"]

        if status == "SUCCEEDED":
            return await on_success(r)
        if status == "FAILED":
            return await on_failure(r)          # not billed upstream
        if status == "UNKNOWN":
            return await on_expired(r)          # NOT the same as failed

        if utcnow() - started > timedelta(hours=deadline_hours):
            return await on_expired(r)
        await sleep(interval)

Three terminal branches, not two. That is the whole point of this article.

Where I ran into all of this

I work on wan-3.run, a hosted browser interface for this model, and every design note above is something we got wrong once before writing it down. Outputs are copied to our own storage the moment a job completes, expired tasks are labelled expired rather than failed, and the model ID and task ID are printed under every result so you can reconcile against your own records. Disclosure: that is our product, and the first clip on it is free.

If you are building your own integration rather than using a front-end, the part I would copy first is the three-branch poller. The storage copy you will remember. The UNKNOWN branch is the one that gets skipped.

Parameter values, state enum and rate limits are from Alibaba Cloud's Model Studio API reference for wan3.0-video, checked 2026-08-27.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.