Batch GPT Image 2.5 from a CSV in Python: async jobs, safe retries
Render one GPT Image 2.5 packshot per CSV row on Sume: mode async, an Idempotency-Key per SKU, a small worker pool, and the 429 queue_full rule.

To render GPT Image 2.5 images for every row of a CSV, submit one job per row to POST /v1/images with mode: "async", give each an Idempotency-Key built from the row's SKU, run a few at a time, and poll each job's status_url until it is terminal. The key makes a rerun of the script safe: the same SKU and payload returns the same job instead of billing a second image.
The script below does that in 30 lines with requests. It is a pattern for a first batch of dozens, not a queue system. Details come from Sume's jobs, errors and generation admission pages, read 2026-10-03.
What does the script look like?
Input is products.csv with columns sku, name, photo_url. Each row sends the product photo as a reference and asks for a clean packshot. Output is one line per SKU with the job's final state and the result_url to fetch images from.
import csv, os, time, requests
from concurrent.futures import ThreadPoolExecutor
API = "https://api.sume.com"
H = {"Authorization": f"Bearer {os.environ['SUME_API_KEY']}", "Content-Type": "application/json"}
def render(row):
body = {"model": "openai/gpt-image-2.5", "quality": "medium", "image_size": "1024x1024",
"mode": "async",
"prompt": f"Studio packshot of {row['name']} on a seamless light grey backdrop, soft shadow, no text.",
"input_references": [{"type": "image_url", "image_url": {"url": row["photo_url"]}}]}
r = requests.post(f"{API}/v1/images", json=body, timeout=60,
headers={**H, "Idempotency-Key": f"packshot-{row['sku']}"})
r.raise_for_status()
d = r.json()["data"]
while True:
s = requests.get(d["status_url"], headers=H, timeout=30).json()
s = s.get("data", s)
if s.get("terminal"):
return row["sku"], s.get("sume_status"), d["result_url"]
time.sleep(s.get("next_poll_after_seconds") or 5)
def main():
with open("products.csv", newline="") as f:
rows = list(csv.DictReader(f))
with ThreadPoolExecutor(max_workers=4) as pool:
for sku, state, url in pool.map(render, rows):
print(sku, state, url)
main()Why async and an idempotency key?
A sync call holds the HTTP request for up to 30 seconds and degrades to a 202 when it runs long; with mode: "async" you always get the job envelope and your own loop owns the waiting. The jobs docs call this the right pattern for anything that can outlast 30 seconds.
The idempotency key matters more. Sume's docs say to send Idempotency-Key on submit requests when retrying after client-side timeouts or network failures, and to reuse a key only for the same operation and payload. Change the prompt for a SKU and you must change its key, or you are reusing a key for a different operation.
What limits will a batch hit?
Sume separates four controls, and each fails differently.
| Control | When full | What you do |
|---|---|---|
| Generation concurrency (plan-based) | Valid jobs are accepted as queued | Nothing; they start as slots free up |
| Queue capacity | 429 queue_full | Wait for jobs to finish or cancel some |
| Submit rate limit | 429 rate_limited | Back off; use retry-after |
| Balance | 402 insufficient_credits | Top up; no job started |
What would I add before a real run?
Handle the 429 and 402 cases around raise_for_status instead of letting the pool stop on the first error, and write results to a file as they arrive so an interruption keeps what is done. Keep workers small. Concurrency is a plan limit that top-ups do not raise, so more threads only add queued jobs.
Run three rows at quality: "low" first to check the prompt, then the whole file at the quality you want. Failed generations are not billed, but completed ones are billed in full, so reviewing a small sample is the cheapest control you have.
- Write
sku, job id, stateto disk as each job finishes. - Skip SKUs already completed when you rerun; the idempotency key protects you if you forget.
- Put a client deadline on the poll loop. A client timeout does not cancel the job.
- Fetch images from
result_urlaftercompleted, then store your own copy.
How do I handle partial failure?
Some rows will fail: a broken photo URL, a rejected prompt, a balance that ran out mid-run. Keep the failure per row rather than aborting the pool: catch the exception inside render, return the SKU with the error text, and write a failures file you can fix and rerun. Because the key is derived from the SKU, rerunning completed rows is safe; rerunning a row with a corrected prompt needs a new key suffix such as packshot-SKU-v2.
A job that ends failed is terminal and is not billed. A job that ends completed is billed in full, which is why the sample-first habit above matters more than any retry logic.
Sources
Related posts
More in Developers
- GPT Image 2.5 seed: can you get the same image twice?
Sume lists seed in the schema but does not serve it: seed returns 400 unsupported_parameter. How to keep a look repeatable with references and masked edits.
- GPT Image 2.5 in TypeScript: fetch that handles 200 and 202
A runnable TypeScript fetch call to Sume's POST /v1/images for GPT Image 2.5, with the 202 job fallback: poll status_url, then read result_url.
- H3 Max Recast job failed: what Sume refunds and what to retry
A failed Recast job releases its hold on Sume. Read the error category, fix input errors, retry queue errors with the same Idempotency-Key, never double-submit.
- H3 Max Recast seed: fal has one, Sume does not send it
fal's Recast API takes and returns a seed. Sume's Video Router accepts none, so each run is a new take. How to re-roll, what to vary, and what it costs.
Written by Sume