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

ClassExampleWhat to do
Bad inputcorrupt file, zero bytes, not an imagedon’t retry — record and tell the uploader
Unsupported for this optransparent source for an op that needs opaquedon’t retry — change the op or skip
Transientrate limit, vendor timeout, networkretry, 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.

References

Browse more image developer guides