Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, quotas, and backpressure when generating PDFs and other documents through an API. Includes retry strategies, queueing patterns, and code examples.

apirate-limitingpdfnode.jsbackendretries

Rate Limits and Quota Management in Document Generation APIs

Document generation is deceptively expensive. A single PDF render can spin up a headless browser, load fonts, fetch remote images, and run layout for several seconds. Multiply that by a few thousand invoices at end-of-month, and your nice REST endpoint suddenly looks like a DDoS target — except the attacker is your own billing cron job.

This is why every serious document API enforces rate limits and quotas. If you generate documents programmatically, you need a clear mental model of how those limits work, and how to design your code to live within them without dropping jobs on the floor.

Two different things: rate limits vs. quotas

It's worth separating the two, because developers often conflate them:

  • Rate limits are short-term throttles. Example: "60 requests per minute per API key." They protect the provider from bursts.
  • Quotas are longer-term caps tied to your plan. Example: "10,000 PDFs per month." They map to billing.

A 429 response usually means you hit the rate limit. A 402 or a structured quota_exceeded error usually means you've burned your monthly allowance. They require different responses — you can retry through a rate limit, but no amount of backoff will fix a quota.

Reading the headers

Most APIs (Kamy included) return standardized rate-limit headers on every response:

http
HTTP/1.1 200 OK X-RateLimit-Limit: 120 X-RateLimit-Remaining: 47 X-RateLimit-Reset: 1731600000 Retry-After: 12

Treat these as a contract. Retry-After tells you exactly how long to wait. X-RateLimit-Remaining lets you slow down before you get throttled. A small helper that logs these headers in your client wrapper is one of the highest-ROI things you can build:

ts
async function callKamy(path: string, init: RequestInit) { const res = await fetch(`https://api.kamy.dev${path}`, init); const remaining = Number(res.headers.get("x-ratelimit-remaining")); if (remaining < 10) { console.warn(`Kamy budget low: ${remaining} requests left`); } return res; }

Handling 429 properly

The wrong way to handle a 429 is to retry immediately in a tight loop. The right way is exponential backoff with jitter, capped at a sane upper bound, and respecting Retry-After when present.

ts
async function renderWithRetry(payload: unknown, attempt = 0): Promise<Response> { const res = await fetch("https://api.kamy.dev/v1/documents", { method: "POST", headers: { "Authorization": `Bearer ${process.env.KAMY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), }); if (res.status !== 429 || attempt >= 5) return res; const retryAfter = Number(res.headers.get("retry-after")); const wait = retryAfter ? retryAfter * 1000 : Math.min(30_000, 2 ** attempt * 500) + Math.random() * 250; await new Promise((r) => setTimeout(r, wait)); return renderWithRetry(payload, attempt + 1); }

The jitter matters. If 50 worker processes all hit a 429 at the same instant and all back off by exactly 2 seconds, they will retry in lockstep and trigger another 429 wave.

Don't retry quota errors

Distinguish error classes explicitly. A typical Kamy error response looks like this:

json
{ "error": { "code": "quota_exceeded", "message": "Monthly document quota reached", "details": { "limit": 10000, "used": 10000 } } }

Your retry function should bail out immediately on quota_exceeded, invalid_template, validation_error, and similar non-transient codes. Retrying these wastes time and pollutes your logs.

ts
const NON_RETRYABLE = new Set([ "quota_exceeded", "invalid_template", "validation_error", "unauthorized", ]);

Queue, don't burst

If you generate documents in batches (statements, invoices, reports), don't dump them into the API as fast as your event loop allows. Put them through a bounded concurrency queue keyed to your published rate limit.

ts
import pLimit from "p-limit"; const limit = pLimit(8); // safely below 120 req/min const results = await Promise.all( invoices.map((inv) => limit(() => renderWithRetry({ template: "invoice", data: inv })) ), );

For larger workloads, push jobs onto a real queue (BullMQ, SQS, Cloud Tasks) with a concurrency setting tuned to your plan. This also gives you durability — if your process crashes mid-batch, the queue retains the unfinished work.

Async rendering for heavy documents

For documents that take more than a couple of seconds — a 200-page report, for example — synchronous endpoints are the wrong tool. They tie up your HTTP connection and amplify the impact of every rate limit hit. Use the async endpoint and a webhook callback:

ts
await fetch("https://api.kamy.dev/v1/documents/async", { method: "POST", headers: { "Authorization": `Bearer ${process.env.KAMY_KEY}` }, body: JSON.stringify({ template: "annual-report", data: report, webhook: "https://yourapp.com/hooks/kamy", }), });

The request returns immediately, the document renders in the background