Running an image batch job and handling the items that fail
A batch is not all-or-nothing. The per-item status, the three failure classes worth separating, and a retry that doesn't reprocess what already worked.
POST /v1/image/batch/submit on Infrai accepts many items and returns a job_id with a total_count. GET /v1/image/batch/status/{id} then reports the job’s status and an items array with per-item outcomes — and that array is the whole point, because in a run of five thousand user-supplied images, some will be corrupt, some will be in a format the operation can’t apply, and the rest will be fine.
Code that branches on the job’s status alone treats a 99.8% success as a failure or a 60% success as a success. Neither is useful.
Submit and read per item
curl -sS -X POST "https://api.infrai.cc/v1/image/batch/submit" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"image": "img_2fVc8nRqLmT4xBzY", "op": "compress", "quality": 82},
{"image": "https://uploads.example.com/user/91/photo.heic", "op": "convert", "format": "webp"}
],
"webhook_url": "https://ops.example.com/hooks/image-batch",
"store": true,
"idempotency_key": "reprocess-uploads-2026-09-chunk0"
}'
{
"ok": true,
"data": { "job_id": "ibj_4kQ9mVzR1sXbNt", "status": "queued", "total_count": 2 }
}
curl -sS "https://api.infrai.cc/v1/image/batch/status/ibj_4kQ9mVzR1sXbNt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"job_id": "ibj_4kQ9mVzR1sXbNt",
"status": "completed",
"items": [
{"image": "img_2fVc8nRqLmT4xBzY", "status": "succeeded",
"image_id": "img_9wQ1zV6pLkS3dHyB", "size_bytes": 184320},
{"image": "https://uploads.example.com/user/91/photo.heic", "status": "failed",
"error": "INVALID_ARGUMENT"}
]
}
}
One succeeded, one failed, job completed. That’s the normal shape of a real batch and your code should treat it as normal.
Three failure classes, three responses
| Class | Example | What to do |
|---|---|---|
| Bad input | corrupt file, zero bytes, not an image | don’t retry — record and tell the uploader |
| Unsupported for this op | transparent source for an op that needs opaque | don’t retry — change the op or skip |
| Transient | rate limit, vendor timeout, network | retry, with backoff |
Conflating the first two with the third is the expensive mistake: a retry loop over a corrupt file is a loop that never succeeds and bills every attempt.
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
PERMANENT = {"INVALID_ARGUMENT", "UNSUPPORTED_MEDIA_TYPE", "MODULE_NOT_FOUND"}
TRANSIENT = {"RATE_LIMIT_ACCOUNT", "RATE_LIMIT_VENDOR", "VENDOR_TIMEOUT", "NETWORK_ERROR"}
TERMINAL = {"completed", "failed", "cancelled"}
def submit(items: list[dict], key: str) -> dict:
resp = SESSION.post(f"{API}/v1/image/batch/submit",
json={"items": items, "store": True, "idempotency_key": key},
timeout=180)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]
def await_batch(job_id: str, budget_seconds: int = 1800) -> dict:
deadline = time.monotonic() + budget_seconds
interval, last = 5, {}
while time.monotonic() < deadline:
resp = SESSION.get(f"{API}/v1/image/batch/status/{job_id}", timeout=60)
resp.raise_for_status()
last = resp.json()["data"]
if (last.get("status") or "").lower() in TERMINAL:
return last
time.sleep(interval)
interval = min(30, interval + 5)
return {**last, "timed_out_waiting": True}
def classify(items: list[dict]) -> dict:
"""Split the outcome three ways. Retrying only the transient failures is what
stops a batch from costing twice what it should."""
done, permanent, transient = [], [], []
for item in items:
state = (item.get("status") or "").lower()
if state in {"succeeded", "completed", "ok"}:
done.append(item)
continue
code = (item.get("error") or "").upper()
(permanent if code in PERMANENT else transient if code in TRANSIENT else permanent).append(item)
return {"succeeded": done, "permanent": permanent, "transient": transient}
def run_with_retry(items: list[dict], run: str, attempts: int = 3) -> dict:
pending, report = items, {"succeeded": 0, "permanent": [], "attempts": 0}
for attempt in range(attempts):
if not pending:
break
report["attempts"] = attempt + 1
job = submit(pending, key=f"{run}-attempt{attempt}")
finished = await_batch(job["job_id"])
split = classify(finished.get("items") or [])
report["succeeded"] += len(split["succeeded"])
report["permanent"] += [i.get("image") for i in split["permanent"]]
# Only the transient failures go round again, and they go with a NEW key so
# the retry is a real attempt rather than an idempotent replay of the last.
pending = [{"image": i.get("image"), "op": "compress", "quality": 82}
for i in split["transient"]]
if pending:
time.sleep(min(60, 5 * (attempt + 1)))
report["still_failing"] = [i["image"] for i in pending]
return report
if __name__ == "__main__":
print(run_with_retry([{"image": os.environ["IMAGE_ID"], "op": "compress", "quality": 82}],
run="reprocess-uploads-2026-09"))
The idempotency key includes the attempt number, which is deliberate: reusing the same key on a retry returns the original job instead of doing the work again, so the retry would report the same failures forever.
Tell the uploader about their file
A permanent failure is usually information the user needs. A HEIC photo their phone produced, a file that was truncated on upload, a PNG that’s actually a renamed PDF — each is a “we couldn’t process your image” message rather than a silent gap in your catalogue.
Route that list somewhere a human or a notification handles: POST /v1/queue/publish for the follow-up, POST /v1/email/send for the message, POST /v1/errors/capture for the ones that look like your bug rather than theirs. All on the same key as the batch, which is why the failure path doesn’t need its own integration.
Size the chunks
A five-thousand-item batch is one job you can’t partially inspect until it finishes. Five hundred items per job gives you progress you can act on, a smaller retry unit, and a cancel that costs less when you notice something wrong.
Use POST /v1/image/batch/cancel/{id} for that case, and put PUT /v1/account/budget/set underneath the whole run.
Limitations
There’s no per-item retry endpoint, so retrying means submitting a new batch of the failures — which is what the code above does, and it means the job ids don’t form a single lineage you can trace. There’s also no partial-result streaming: items become visible in the status response as the job progresses, but there’s no push per item, only the job-level webhook_url.
And the error codes on individual items are coarse: INVALID_ARGUMENT covers a corrupt file and an unsupported combination alike, so telling a user precisely what was wrong with their upload sometimes needs a POST /v1/image/metadata call on the original to find out. Cloudinary’s bulk APIs and a dedicated pipeline tool give more granular per-item diagnostics if bulk processing is the core of your product.
Batch submission and status report billing_class: free in discovery; the per-item operations are what bill, at rates live in GET /v1/discovery/image.compress and its siblings (verified 2026-09-21), with platform rates drifting downward as vendor contracts improve.