Skip to content

Handle errors and retries

The Pagr SDKs draw one line and hold it: protocol failures raise, business outcomes don’t. Get that distinction right and your error handling is three catch blocks and an if. Get it wrong and you’ll wrap try around things that never throw while ignoring the field that actually tells you the render failed.

Example How you see it
Protocol failure Bad API key, template not found, payload too large, connection dropped An exception
Business outcome Document failed validation, out of page credit, batch partially rendered Data on the result object

A render that produces no document is not an error. The request worked; the answer was “no”. So:

# Wrong — the failure never raises, so this branch never runs
try:
result = await client.render(template_id, data)
except PagrError:
print("render failed") # ← unreachable for a validation failure
# Right — catch the transport, inspect the result
try:
result = await client.render(template_id, data)
except PagrError as exc: # bad key, 404, timeout, connection reset…
...
if not result.ok: # validation, credit — the actual render outcome
...

Every SDK maps the API’s statuses onto the same shape, and folds transport failures into it too — so one catch on the base type catches everything the SDK can produce, and you never see a raw HTTP-library exception leak through.

Cause Python TypeScript Java C# Ruby C++
base PagrError PagrError PagrException PagrApiException Pagr::Error pagr::PagrApiException
401 AuthenticationError AuthenticationError AuthenticationException PagrAuthenticationException AuthenticationError PagrAuthenticationException
403 ForbiddenError ForbiddenError ForbiddenException PagrForbiddenException ForbiddenError PagrForbiddenException
404 NotFoundError NotFoundError NotFoundException PagrNotFoundException NotFoundError PagrNotFoundException
413 PayloadTooLargeError PayloadTooLargeError PayloadTooLargeException PagrPayloadTooLargeException PayloadTooLargeError PagrPayloadTooLargeException
422 ValidationFailedError ValidationFailedError ValidationFailedException PagrValidationFailedException ValidationFailedError PagrValidationFailedException
429 RateLimitError RateLimitError RateLimitException PagrRateLimitException RateLimitError PagrRateLimitException
other 4xx/5xx ApiError ApiError ApiException PagrApiException ApiError PagrApiException
timeout PagrTimeoutError PagrTimeoutError PagrTimeoutException PagrTimeoutException PagrTimeoutError PagrTimeoutException
connection / DNS / TLS PagrConnectionError PagrConnectionError PagrConnectionException PagrConnectionException PagrConnectionError PagrConnectionException
unparseable body PagrDecodeError PagrDecodeError PagrDecodeException PagrDecodeException PagrDecodeError PagrDecodeException

Every exception carries status_code and the API’s machine-readable code when the response provided them; both are absent for transport failures. RateLimitError additionally carries retry_after.

  1. Catch the base type at the boundary of whatever unit of work you’re doing. That guarantees no HTTP-library exception escapes.

  2. Catch the specific types you can actually act on — a 404 means fix the template id, a 401 means fix the key, a 429 means slow down. Everything else is usually “log and surface”.

  3. Then inspect the result for ok / status / issues.

  4. Never blindly retry a write. Read on for why.

from pagr import (
PagrApiClient, PagrError, AuthenticationError, NotFoundError,
RateLimitError, PayloadTooLargeError, PagrTimeoutError,
)
async def render_invoice(client, template_id, data):
try:
result = await client.render(template_id, data, include_document=True)
except AuthenticationError:
raise ConfigError("Pagr API key is invalid or revoked") # not retryable
except NotFoundError as exc:
raise ConfigError(f"Template {template_id} not found ({exc.code})")
except PayloadTooLargeError:
raise ValueError("Invoice payload exceeds the 50 MB limit")
except RateLimitError as exc:
# No Retry-After from this API — back off with your own policy.
raise Backoff(seconds=exc.retry_after or 30)
except PagrTimeoutError:
# Do NOT re-render: it may already have rendered and charged.
raise Uncertain("Render timed out; reconcile via get_documents()")
except PagrError as exc: # catch-all
raise Transient(f"Pagr call failed: {exc}") from exc
# Business outcome — no exception involved.
if not result.ok:
if result.insufficient_credit:
raise OutOfCredit(result.message)
raise InvalidData([str(i) for i in result.issues])
return result.document

Every SDK ships the same retry policy, and the shape of it is deliberate.

Retried?
GET (list, fetch, download, job status, fonts, stats, health) ✅ Yes
POST / PATCH (render, validate, enqueue, document-name update) Never
HTTP 500, 502, 503, 504 ✅ on a GET
Timeouts, connection resets, DNS failures ✅ on a GET
HTTP 429 Never
Any other 4xx ❌ Never (deterministic — the same request gets the same answer)

Backoff is capped exponential with full jitter, and honours a Retry-After header when one is present (clamped defensively so a hostile value can’t park your call indefinitely).

Setting Default
Retries 2 (3 attempts total); 0 disables
First backoff step 500 ms, doubling per attempt
Backoff ceiling 8 s
Retry-After ceiling 60 s

The API has no idempotency keys. A render request that was applied but whose response was lost is indistinguishable, from the client, from one that never arrived. Retrying it would render the document twice and charge twice.

So when a render times out, the honest answer is “I don’t know”. Resolve it by looking, not by retrying:

# A render timed out. Did it land? Check the document list.
page = await client.get_documents(
take=25,
sort_by="renderedAt",
sort_direction="desc",
filters=[{"field": "template.guid", "value": str(template_id)}],
)
already = [d for d in page.items if d.document_name == expected_name]
if not already:
result = await client.render(template_id, data) # safe to re-issue

A rate limit reflects your own request volume over a sliding 60-second window. The SDK’s backoff ceiling is 8 seconds — nowhere near long enough to clear it — and the API sends no Retry-After. So a silent client retry would burn attempts and still fail. RateLimitError surfaces instead, so you can lower concurrency or spread the calls out. See Errors → Rate limits for the per-category limits.

  • Tune retries per client, not per call. Every SDK takes max_retries at construction (0 disables). There’s no per-call override — see Configure the client.
  • A slow render needs a bigger timeout, not more retries. The 30-second default is below the server’s 60-second render budget, so a heavy document can time out on a request that would have succeeded. Pass a per-call timeout.
  • PagrDecodeError usually means you’re not talking to Pagr. It fires when a 2xx body isn’t the expected JSON — typically a proxy, captive portal, or login page intercepting the request. Check the base URL before debugging the SDK.
  • 404 vs 403 on someone else’s resource. Cross-tenant access returns 404, not 403 — the resource genuinely doesn’t exist for your key. A 403 means your own organisation lacks permission for the action.
  • Batch and job issue lists are capped, counts are not. An async job persists at most 100 issues. Trust renderedCount / missingCount; treat issues as a diagnostic sample.
  • Webhook callbacks are delivered once, with no retries. If your endpoint is down, that callback is gone. Use polling as the source of truth for anything you must not miss — see Run renders in the background.
  • Check health at start-up, not per request. get_status is a cheap probe; calling it before every render just doubles your request count.