/v1/videos 409 job_failed vs job_not_completed in retry loops
On /v1/videos/{id}/content, 409 job_not_completed is retryable and means keep polling; 409 job_failed is not retryable. Branch on the code, not the 409.

Both errors are HTTP 409 on GET /v1/videos/{id}/content, so a retry loop must read the code. job_not_completed says the job is still running: it advertises retryable: true and a poll_status next action. job_failed says the job ended in failure: retryable: false and next_action: "inspect_events".
The two 409s
The difference is the whole point of having two codes.
| Code | Meaning | retryable | Next action |
|---|---|---|---|
job_not_completed | Job still running | true | poll_status |
job_failed | Terminal failure | false | inspect_events |
| 409 conflict | Idempotency-Key reused with a different body | n/a | Send a new key |
Why two codes
The docs say job_failed is intentionally not job_not_completed. Otherwise a client would poll forever for content that will never exist.
A retry-safe download
This script fetches the content and prints the error code if the answer is a 409. The code field is read defensively because the exact error body shape is not spelled out in the docs.
import asyncio, os
import httpx
async def main():
headers = {"Authorization": f"Bearer {os.environ['SUME_API_KEY']}"}
job_id = os.environ["JOB_ID"]
async with httpx.AsyncClient(base_url="https://api.sume.com", headers=headers, follow_redirects=True) as c:
r = await c.get(f"/v1/videos/{job_id}/content", params={"index": 0})
if r.status_code == 409:
body = r.json()
err = body.get("error") if isinstance(body.get("error"), dict) else body
print("409:", err.get("code"))
else:
r.raise_for_status()
open("out.mp4", "wb").write(r.content)
asyncio.run(main())Poll first
Better still, poll GET /v1/videos/{id} until the status is terminal and only then ask for content. On failed, read the poll error string, which is the public remap of the failure.
Sources
Related posts
More in Developers
- /v1/videos canonical_slug: the stable model id to store
Store canonical_slug (or id) from GET /v1/videos/models, not the display name. On Sume ids are bare, like seedance-2, with no org prefix.
- /v1/videos id and generation_id are one Sume job id: store one
On Sume the id and generation_id in a /v1/videos poll are the same job id, unlike OpenRouter's two ids. Store one and reuse it on /v1/jobs routes.
- /v1/videos model_not_found: 404 now, not 400. Fix retry logic
An unknown model id on /v1/videos returns 404 model_not_found. It was 400 through #2311 and changed in #2321. Update clients that match on 400.
- /v1/videos size 1920x1080 returns 400: send resolution instead
POST /v1/videos rejects size with 400 unsupported_parameter because every model reports supported_sizes null. Send resolution plus aspect_ratio.
Written by Sume