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.
The one rule
Section titled “The one rule”| 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 runstry: result = await client.render(template_id, data)except PagrError: print("render failed") # ← unreachable for a validation failure
# Right — catch the transport, inspect the resulttry: 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 ...The exception tree
Section titled “The exception tree”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.
Handling it in practice
Section titled “Handling it in practice”-
Catch the base type at the boundary of whatever unit of work you’re doing. That guarantees no HTTP-library exception escapes.
-
Catch the specific types you can actually act on — a
404means fix the template id, a401means fix the key, a429means slow down. Everything else is usually “log and surface”. -
Then inspect the result for
ok/status/issues. -
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.documentimport { PagrError, AuthenticationError, NotFoundError, RateLimitError, PayloadTooLargeError, PagrTimeoutError,} from 'pagr';
async function renderInvoice(client, templateId, data) { let result; try { result = await client.render(templateId, data, { includeDocument: true }); } catch (err) { if (err instanceof AuthenticationError) throw new ConfigError('API key invalid or revoked'); if (err instanceof NotFoundError) throw new ConfigError(`Template ${templateId} not found`); if (err instanceof PayloadTooLargeError) throw new Error('Payload exceeds 50 MB'); if (err instanceof RateLimitError) throw new Backoff(err.retryAfter ?? 30); // Do NOT re-render on timeout: it may already have rendered and charged. if (err instanceof PagrTimeoutError) throw new Uncertain('Render timed out'); if (err instanceof PagrError) throw new Transient(err.message); throw err; // not ours — rethrow }
// Business outcome — no exception involved. if (!result.ok) { if (result.insufficientCredit) throw new OutOfCredit(result.message); throw new InvalidData(result.issues.map((i) => i.description)); } return result.document!;}import org.example.exception.*;import org.example.models.RenderResult;
RenderedDocument renderInvoice(PagrApiClient client, UUID templateId, Object data) { RenderResult result; try { result = client.render(templateId, data, RenderOptions.builder().includeDocument(true).build()); } catch (AuthenticationException exc) { throw new ConfigError("Pagr API key is invalid or revoked"); } catch (NotFoundException exc) { throw new ConfigError("Template " + templateId + " not found: " + exc.getCode()); } catch (PayloadTooLargeException exc) { throw new IllegalArgumentException("Payload exceeds the 50 MB limit"); } catch (RateLimitException exc) { throw new Backoff(exc.getRetryAfter()); } catch (PagrTimeoutException exc) { // Do NOT re-render: it may already have rendered and charged. throw new Uncertain("Render timed out; reconcile via getDocuments()"); } catch (PagrException exc) { // catch-all throw new Transient("Pagr call failed", exc); }
// Business outcome — no exception involved. if (!result.isOk()) { if (result.isInsufficientCredit()) throw new OutOfCredit(result.getMessage()); throw new InvalidData(result.getIssues()); } return result.getDocument();}PagrException extends RuntimeException, so none of these are checked.
using Pagr.Sdk.Exceptions;
async Task<RenderedDocument> RenderInvoiceAsync( PagrApiClient client, Guid templateId, object data){ RenderResult result; try { result = await client.RenderAsync(templateId, data, includeDocument: true); } catch (PagrAuthenticationException) { throw new ConfigException("Pagr API key is invalid or revoked"); } catch (PagrNotFoundException exc) { throw new ConfigException($"Template {templateId} not found: {exc.Code}"); } catch (PagrPayloadTooLargeException) { throw new ArgumentException("Payload exceeds the 50 MB limit"); } catch (PagrRateLimitException exc) { throw new BackoffException(exc.RetryAfter ?? TimeSpan.FromSeconds(30)); } catch (PagrTimeoutException) { // Do NOT re-render: it may already have rendered and charged. throw new UncertainException("Render timed out; reconcile via GetDocumentsAsync"); } catch (PagrApiException exc) // catch-all (also the generic case) { throw new TransientException("Pagr call failed", exc); }
// Business outcome — no exception involved. if (!result.Ok) { if (result.InsufficientCredit) throw new OutOfCreditException(result.Message); throw new InvalidDataException(string.Join("; ", result.Issues)); } return result.Document!;}Order matters: PagrApiException is the base, so it must come last.
def render_invoice(client, template_id, data) begin result = client.render(template_id, data, include_document: true) rescue Pagr::AuthenticationError raise ConfigError, "Pagr API key is invalid or revoked" rescue Pagr::NotFoundError => e raise ConfigError, "Template #{template_id} not found: #{e.code}" rescue Pagr::PayloadTooLargeError raise ArgumentError, "Payload exceeds the 50 MB limit" rescue Pagr::RateLimitError => e raise Backoff, (e.retry_after || 30) rescue Pagr::PagrTimeoutError # Do NOT re-render: it may already have rendered and charged. raise Uncertain, "Render timed out; reconcile via client.documents" rescue Pagr::Error => e # catch-all raise Transient, "Pagr call failed: #{e.message}" end
# Business outcome — no exception involved. unless result.ok? raise OutOfCredit, result.message if result.insufficient_credit? raise InvalidData, result.issues.map(&:to_s) end result.documentendrescue Pagr::Error must come last — it’s the base class.
#include "pagr/exceptions.hpp"
pagr::RenderedDocument render_invoice(pagr::PagrApiClient& client, const std::string& template_id, const std::string& data) { pagr::RenderResult result; try { result = client.render(template_id, data, {.include_document = true}); } catch (const pagr::PagrAuthenticationException&) { throw ConfigError("Pagr API key is invalid or revoked"); } catch (const pagr::PagrNotFoundException& exc) { throw ConfigError("Template not found: " + exc.what()); } catch (const pagr::PagrPayloadTooLargeException&) { throw std::invalid_argument("Payload exceeds the 50 MB limit"); } catch (const pagr::PagrRateLimitException& exc) { throw Backoff(exc.retry_after()); } catch (const pagr::PagrTimeoutException&) { // Do NOT re-render: it may already have rendered and charged. throw Uncertain("Render timed out; reconcile via get_documents"); } catch (const pagr::PagrApiException& exc) { // catch-all (also the generic case) throw Transient(exc.what()); }
// Business outcome — no exception involved. if (!result.ok()) { if (result.insufficient_credit()) throw OutOfCredit(result.status); throw InvalidData(result.issues); } return *result.document;}Catch derived types before PagrApiException, or the base handler wins.
What the SDKs retry for you
Section titled “What the SDKs retry for you”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 |
Why writes are never retried
Section titled “Why writes are never retried”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-issueconst page = await client.getDocuments({ take: 25, sortBy: 'renderedAt', sortDirection: 'desc', filters: [{ field: 'template.guid', value: templateId }],});const already = page.items.some((d) => d.documentName === expectedName);if (!already) await client.render(templateId, data); // safe to re-issuePagedResult<RenderDocument> page = client.getDocuments(ListOptions.builder() .take(25).sortBy("renderedAt").sortDirection(SortDirection.DESCENDING) .filter(new Filter("template.guid", templateId.toString())) .build());
boolean already = page.getItems().stream() .anyMatch(d -> d.getDocumentName().equals(expectedName));if (!already) client.render(templateId, data); // safe to re-issuevar page = await client.GetDocumentsAsync(new ListOptions{ Take = 25, SortBy = "renderedAt", SortDirection = SortDirection.Descending, Filters = [Filter.Eq("template.guid", templateId)],});
if (!page.Items.Any(d => d.DocumentName == expectedName)) await client.RenderAsync(templateId, data); // safe to re-issueFilter.Eq(field, Guid) formats the id for you.
page = client.documents( take: 25, sort_by: "renderedAt", sort_direction: "desc", filters: [{ field: "template.guid", value: template_id }],)already = page.items.any? { |d| d.document_name == expected_name }client.render(template_id, data) unless already # safe to re-issuepagr::ListOptions options;options.take = 25;options.sort_by = "renderedAt";options.sort_direction = pagr::SortDirection::Descending;options.filters.emplace_back("template.guid", template_id);
const auto page = client.get_documents(options);// …check page.items for expected_name before re-issuing the renderWhy 429 is not retried
Section titled “Why 429 is not retried”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.
Other considerations
Section titled “Other considerations”- Tune retries per client, not per call. Every SDK takes
max_retriesat construction (0disables). 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.
PagrDecodeErrorusually means you’re not talking to Pagr. It fires when a2xxbody isn’t the expected JSON — typically a proxy, captive portal, or login page intercepting the request. Check the base URL before debugging the SDK.404vs403on someone else’s resource. Cross-tenant access returns404, not403— the resource genuinely doesn’t exist for your key. A403means 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; treatissuesas 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_statusis a cheap probe; calling it before every render just doubles your request count.
Related articles
Section titled “Related articles”- Errors — every status code, error code, render issue type, and data limit.
- Configure the client — timeouts, retry counts, and key rotation.
- Validate before rendering — turn render issues into feedback before you’re charged.
- Service status — the health and version probes.
- Troubleshooting — general problem solving.
