Sume video content 409: job_not_completed vs job_failed in Python
A 409 from /v1/videos/{id}/content means two things. job_not_completed is retryable, job_failed is not. Here is a Python handler that tells them apart.

GET /v1/videos/{id}/content returns 409 in two cases. job_not_completed means the job is still running and you should poll again, while job_failed means the job ended in failure and will never produce a file, so you should stop and read the error.
Treating both as the same is a bug. A client that retries job_failed forever waits for a file that does not exist.
The two 409 codes
The docs are explicit about the difference. job_not_completed advertises retryable: true and a poll_status next action. job_failed advertises retryable: false and next_action: inspect_events, with the same public message that the poll response shows in error.
Sume made job_failed a separate code on purpose, because the retryable code would send clients into an endless poll.
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 409 | job_not_completed | job still running | keep polling the job, then retry |
| 409 | job_failed | terminal failure | stop, read error on the poll response |
| 404 | job_not_found | unknown or foreign job | check the id and the key |
| 401 | unauthorized | missing or invalid key | check SUME_API_KEY |
The handler
The function below returns the redirect location on success, returns None while the job is running, and raises on a failure. It reads the error body, which holds the code, and does not follow the redirect so you can see the artifact URL.
import json, os, urllib.error, urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
opener = urllib.request.build_opener(NoRedirect)
def content_url(job_id):
req = urllib.request.Request(
"https://api.sume.com/v1/videos/" + job_id + "/content?index=0",
headers={"Authorization": "Bearer " + os.environ["SUME_API_KEY"]})
try:
opener.open(req, timeout=30)
except urllib.error.HTTPError as e:
if e.code in (301, 302, 303, 307, 308):
return e.headers["Location"]
body = e.read().decode()
if e.code == 409 and "job_not_completed" in body:
return None
raise RuntimeError(str(e.code) + " " + body)
return NoneHow to use it
Call it after your poll loop sees completed, and the result should be a URL. If you call it early, you get None and can wait. If it raises, log the message and fetch the job to read error, since the same public text is there.
The poll loop in the Python polling post is the better place to wait. The content endpoint is for the final fetch.
Common mistakes
Matching on the HTTP status alone loses the distinction, so read the code from the body. Retrying on every 409 hides failures, and so does swallowing the error text.
- Match the code string, not just 409.
- Do not retry
job_failed. - Log the job id with the error.
- Read the error text on the poll response; it is the public reason for the failure.
Related posts
More in Developers
- Fields Omni rejects on Sume: generate_audio false, bitrate_mode
Which request fields Sume's gemini-omni-flash-1.1 refuses or lacks: generate_audio false, bitrate_mode, reference_audio_urls, plus edit-mode rules.
- Filter the Sume video catalog in Python for 1080p and audio references
Instead of guessing which video model takes what, read GET /v1/videos/models and filter it. This Python script lists models with 1080p and audio_url references.
- Find gpt-image-1 in your repo before Oct 23: a Python scan
OpenAI shuts gpt-image-1 down on 2026-10-23, five weeks before the other GPT Image ids. A Python scan splits them by date and maps each hit to a Sume model id.
- Find video models with 1:1, 9:16 and 16:9: filter the Sume catalog
One Python call to GET /v1/videos/models lists which Sume video models cover square, vertical and widescreen, so one prompt can feed Feed, Reels and in-stream.
Written by Sume