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.

5 min readSume
All posts

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.

Limits that matter to a batch, from Sume's generation admission page, read 2026-10-03.
ControlWhen fullWhat you do
Generation concurrency (plan-based)Valid jobs are accepted as queuedNothing; they start as slots free up
Queue capacity429 queue_fullWait for jobs to finish or cancel some
Submit rate limit429 rate_limitedBack off; use retry-after
Balance402 insufficient_creditsTop 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, state to 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_url after completed, 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

All Developers posts

Written by Sume