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.
| Failure | With job id stored | Without |
|---|---|---|
| Worker restarts mid-poll | resume polling | resubmit, or lose the work |
| Poll budget exceeded | requeue the id, check later | job orphaned |
| Network error on submit | retry with the same key, same job | possible duplicate charge |
| Job failed | read error, decide | indistinguishable 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.