Long PDF jobs: polling, timeouts and retries without duplicates

Some operations return a job instead of a document. The polling loop, the idempotency key that makes a retry free, and why your timeout is not a failure.

Most Infrai PDF operations answer immediately. The expensive ones — a hundred-page OCR, a large merge, a conversion to docx — can take long enough that you’re better off treating them as jobs, and GET /v1/pdf/job/get/{job_id} is the read that reports status, result and error. The rest of the pattern is the same discipline every asynchronous API needs, with one detail that’s specific to paying per call.

Set an idempotency_key and a retry costs nothing. Skip it and every retry is another charge for work you already paid for.

Poll the job

curl -sS "https://api.infrai.cc/v1/pdf/job/get/pjb_2fVc8nRqLmT4xBzY" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "job_id": "pjb_2fVc8nRqLmT4xBzY",
    "status": "running",
    "result": null,
    "error": null
  }
}

Three fields, and the discipline is to branch on status rather than on whether result is present. A job that finished with an error has a null result and a populated error, and code that checks result first reads that as “not done yet” and polls forever.

The key that makes retries free

curl -sS -X POST "https://api.infrai.cc/v1/pdf/ocr" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "pdf": "https://files.example.com/scans/archive-box-17.pdf",
    "lang": "en",
    "quality": "balanced",
    "idempotency_key": "ocr-archive-box-17-v1"
  }'

Derive the key from the work, not from the attempt. ocr-archive-box-17-v1 is stable across retries of the same intent; a fresh UUID per attempt makes each retry a new billable job, which is exactly the outcome the key exists to prevent.

Bump the version suffix only when you genuinely want the work done again — different quality, a re-scanned source.

A worker that behaves

import os
import time

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
TERMINAL = {"succeeded", "failed", "cancelled", "done", "error"}
RETRYABLE = {"RATE_LIMIT_ACCOUNT", "RATE_LIMIT_VENDOR", "VENDOR_TIMEOUT", "NETWORK_ERROR"}
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def start_ocr(pdf: str, key: str, attempts: int = 4) -> dict:
    """Retry only transport failures, always with the same idempotency key. A retry
    that turns out to be a duplicate returns the original job instead of starting
    — and paying for — a second one."""
    body = {"pdf": pdf, "lang": "en", "quality": "balanced", "idempotency_key": key}
    for attempt in range(attempts):
        try:
            resp = SESSION.post(f"{API}/v1/pdf/ocr", json=body, timeout=90)
        except requests.RequestException:
            time.sleep(min(30, 2 ** attempt))
            continue
        payload = resp.json()
        if payload.get("ok"):
            return payload["data"]
        code = payload.get("error", {}).get("code")
        if code == "IDEMPOTENCY_KEY_CONFLICT":
            raise RuntimeError("same key, different parameters — fix the caller")
        if code not in RETRYABLE:
            raise RuntimeError(f"refused: {code}")
        time.sleep(min(30, 2 ** attempt))
    raise RuntimeError("submit retries exhausted")


def await_job(job_id: str, budget_seconds: int = 900) -> dict:
    """Stop waiting after OUR budget, and say so — the job keeps running, so losing
    the job_id here is how you pay for work you then never collect."""
    deadline = time.monotonic() + budget_seconds
    interval, last = 3, {}
    while time.monotonic() < deadline:
        resp = SESSION.get(f"{API}/v1/pdf/job/get/{job_id}", timeout=25)
        resp.raise_for_status()
        last = resp.json()["data"]
        if (last.get("status") or "").lower() in TERMINAL:
            return last
        time.sleep(interval)
        interval = min(15, interval + 2)
    return {"job_id": job_id, "status": last.get("status", "running"), "timed_out_waiting": True}


def ocr_document(pdf: str, work_id: str) -> dict:
    started = start_ocr(pdf, key=f"ocr-{work_id}-v1")
    # A synchronous response has the text already; only a job needs polling.
    if "text_per_page" in started:
        return {"mode": "sync", "pages": len(started["text_per_page"]),
                "confidence": started.get("confidence_avg")}
    finished = await_job(started.get("job_id", ""))
    return {"mode": "job", "status": finished.get("status"),
            "timed_out": finished.get("timed_out_waiting", False),
            "error": finished.get("error")}


if __name__ == "__main__":
    print(ocr_document("https://files.example.com/scans/archive-box-17.pdf", "archive-box-17"))

Two design choices worth copying. The function handles both a synchronous result and a job, because which you get depends on the size of the work rather than on the endpoint. And the timeout returns a state rather than raising, so the job_id survives — a worker that throws on its own impatience loses the handle to work you’re paying for.

Store the job id before you wait

The sequence that fails is worth walking through.

You submit, you start polling, the process crashes for an unrelated reason, the job finishes normally on the platform’s side, and nobody ever collects it — so you have paid for an OCR of a hundred pages whose result exists, is addressable, and is known to no part of your system, which is the most annoying possible way to waste money because everything worked.

Write the job_id and the idempotency key to your own store before the first poll. Then a restarted worker can resume polling instead of resubmitting, and if it does resubmit, the key returns the same job.

FailureWith job id storedWithout
Worker restarts mid-pollresume pollingresubmit, or lose the work
Poll budget exceededrequeue the id, check laterjob orphaned
Network error on submitretry with the same key, same jobpossible duplicate charge
Job failedread error, decideindistinguishable from “slow”

Don’t poll tightly

An OCR of a hundred pages is not going to finish in the next 200 milliseconds. Start at a few seconds, ease up to fifteen, and put the whole thing on POST /v1/queue/publish so a long job occupies a worker rather than a request handler.

That queue is on the same credential as the PDF operation and the storage the result lands in, which is the practical argument here: an asynchronous document pipeline needs a queue, a store and somewhere to record failures, and having all three on one key means the pipeline is one integration rather than four.

Limitations

There’s no cancel for a PDF job, so a large operation submitted by mistake runs to completion — the control you have is not submitting it, which makes a size check before a hundred-page OCR worth writing. And there’s no webhook on completion for these operations, so polling is the mechanism rather than a choice.

Whether an operation returns a job or a document isn’t something you can force either way, so your code has to handle both shapes. Gotenberg self-hosted gives you control over the worker pool and the timeout behaviour, which matters if you need predictable latency under load — a fair reason to run your own for high-volume, latency-sensitive conversion.

Operations bill per call, live in GET /v1/discovery/pdf.ocr and its siblings (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.

References

Browse more pdf developer guides