Poll a 30-second Seedance 2.5 job in Python with a 20-minute deadline

Submit a 30 s Seedance 2.5 video to Sume, poll GET /v1/videos/{id} with capped backoff, and stop at a deadline without losing the job. Runnable stdlib Python.

4 min readSume
All posts

To poll a long AI video job, submit it once, keep the returned job id, and call GET /v1/videos/{id} on a growing delay until the status is completed, failed or cancelled. On Sume a 30-second Seedance 2.5 request is one POST to /v1/videos with model seedance-2.5 and duration 30, and the loop below stops after 20 minutes without cancelling the job.

Why a deadline, and why not a longer HTTP wait

Seedance 2.5 is in the Sume catalog at 4 to 30 seconds and 480p, 720p or 1080p. A clip at the top of that range is minutes of work, not seconds. Sume answers the submit with 202 and a polling_url immediately, so no HTTP request has to stay open. The wait lives in your process, and your process owns the deadline.

Hitting your own deadline is not a failure of the job. The Sume jobs guide is explicit that a client-side timeout does not cancel anything: the job keeps running and keeps billing. So the loop prints the job id and exits instead of submitting again.

A good deadline is a number you can defend. Twenty minutes is the figure the Sume TypeScript SDK uses as the default wait for generation jobs, because video and avatar-video jobs usually run for minutes. Pick your own, put it in config, and log it next to the job id so a person reading the log knows the wait was cut by policy and not by the job.

  • Submit once, with an Idempotency-Key, so a retried submit returns the original job.
  • Poll the polling_url from the 202 response. The shape follows the OpenRouter video guide: pending, in_progress, completed, failed.
  • Stop on any terminal status. Sume spells the canceled state cancelled on this route.
  • Grow the delay and cap it, so a 20-minute wait costs a few dozen reads.

Status names on the wire

Sume jobs have five internal statuses. The /v1/videos route maps them to the OpenRouter-shaped names, and the same job is readable at /v1/jobs/{id}/status in the normal Sume envelope.

Sume job status to /v1/videos status (Sume docs, read 2026-10-05)
Sume job.status/v1/videos statusTerminal
queuedpendingNo
processingin_progressNo
completedcompletedYes
failedfailedYes
canceledcancelledYes

The script

Set SUME_API_KEY, then run it. It uses only the standard library.

import json, os, time, urllib.request, urllib.error
BASE = "https://api.sume.com"
def call(method, path, body=None, headers=None):
    req = urllib.request.Request(
        BASE + path, method=method,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": "Bearer " + os.environ["SUME_API_KEY"],
                 "Content-Type": "application/json", **(headers or {})})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"{}")
body = {"model": "seedance-2.5", "duration": 30, "resolution": "1080p",
        "aspect_ratio": "16:9",
        "prompt": "Slow dolly through a night market in the rain, neon signs"}
code, job = call("POST", "/v1/videos", body, {"Idempotency-Key": "market-30s-001"})
assert code == 202, job
job_id, wait, deadline = job["id"], 2.0, time.time() + 20 * 60
while time.time() < deadline:
    code, job = call("GET", "/v1/videos/" + job_id)
    if code == 200 and job["status"] in ("completed", "failed", "cancelled"):
        break
    time.sleep(wait)
    wait = min(wait * 1.5, 30)
else:
    raise SystemExit("deadline reached, job still running: " + job_id)
print(job["status"], job.get("unsigned_urls") or job.get("error"))

What each poll costs you

A poll is a GET, so it spends the read budget, not the write budget. On the Free plan that is 4,800 reads a minute against 120 writes, according to the Sume authentication page. The loop above makes about 5 reads in its first minute and 2 a minute at the 30-second cap. Rate limits are not the thing to worry about for one job; they matter when you run hundreds, which the batch posts below cover.

If a poll returns 429, the loop keeps going because it only breaks on a 200 with a terminal status. For a hardened version, honour the retry-after header, which MDN defines as either a number of seconds or an HTTP date.

Backoff has a second benefit beyond rate limits: it keeps logs readable. A fixed one-second poll writes 1,200 identical lines for a 20-minute wait, while the 2, 3, 4.5 and up to 30 second schedule writes about 45. If you log each poll, you will want the second.

Gotchas

  • Do not re-POST on timeout. A second POST without the same Idempotency-Key is a second paid job.
  • Keep the job id somewhere durable. If the process dies at minute 12, GET /v1/videos/{id} still works at minute 13.
  • Other vendors poll too. Google's Gemini Omni docs poll a file state for large videos, so the loop shape is portable.
  • OpenAI's deprecations page lists the Videos API and Sora 2 aliases as removed on September 24, 2026. A client written to the OpenRouter-shaped wire has nothing to unlearn if you move providers.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume