Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, retries, and quota budgets when generating PDFs and other documents through an API — without blowing up production.

rate-limitingapi-designpdfnode.jsbackend

Rate Limits and Quota Management in Document Generation APIs

Document generation is deceptively expensive. Each PDF render might spin up a headless browser, load fonts, fetch images, and serialize a few megabytes of output. That's why nearly every document API — Kamy included — enforces rate limits and quotas. If your integration ignores them, you'll see cascading failures the first time a customer uploads a 10,000-row CSV and asks for invoices.

This post covers how rate limits typically work in document APIs, how to detect them, and how to design your backend so you stay within budget without dropping jobs on the floor.

The two things being limited

Document APIs usually enforce two separate controls:

  • Rate limits: requests per second or per minute. Protects the API from bursts.
  • Quotas: documents per billing period. Protects your invoice.

They fail differently. A rate limit returns 429 Too Many Requests and is usually transient — you can retry. A quota breach returns something like 402 Payment Required or 403 Forbidden and will keep failing until the period rolls over or you upgrade.

Your code needs to tell these apart.

Reading the response headers

Most providers expose current state via headers. A typical response from Kamy looks like:

http
HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1715000000 X-Quota-Limit: 10000 X-Quota-Remaining: 7421 X-Quota-Reset: 1717545600

Log these. Even when requests succeed, you want visibility into how close you are to the ceiling. A simple middleware wrapper does the job:

ts
async function renderWithTelemetry(payload: RenderPayload) { const res = await fetch("https://api.kamy.dev/v1/documents", { method: "POST", headers: { "Authorization": `Bearer ${process.env.KAMY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), }); metrics.gauge("kamy.rate.remaining", Number(res.headers.get("X-RateLimit-Remaining"))); metrics.gauge("kamy.quota.remaining", Number(res.headers.get("X-Quota-Remaining"))); return res; }

Once you have kamy.quota.remaining on a dashboard, you can alert at 20% and avoid 3 a.m. surprises.

Handling 429 the right way

The naive approach — retry immediately — makes things worse. The correct approach uses the Retry-After header with exponential backoff and jitter:

ts
async function renderWithRetry(payload: RenderPayload, maxAttempts = 5) { for (let attempt = 0; attempt < maxAttempts; attempt++) { const res = await renderWithTelemetry(payload); if (res.status !== 429) return res; const retryAfter = Number(res.headers.get("Retry-After") ?? 1); const backoff = Math.min(retryAfter * 1000, 2 ** attempt * 500); const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, backoff + jitter)); } throw new Error("Rate limit retries exhausted"); }

A few things to note:

  • Respect Retry-After when the server sends it. It knows better than your backoff formula.
  • Jitter prevents thundering herds when multiple workers hit the limit simultaneously.
  • Cap retries. A document that can't be generated in 30 seconds should go to a dead-letter queue, not retry forever.

Shaping traffic on your side

Reactive retries are the fallback. The primary strategy should be: don't exceed the limit in the first place. For bulk workloads, put a client-side limiter in front of the API.

ts
import pLimit from "p-limit"; // Stay well below the 60 req/min ceiling const limit = pLimit(8); async function generateInvoices(invoices: Invoice[]) { return Promise.all( invoices.map((invoice) => limit(() => renderWithRetry({ template: "invoice", data: invoice })) ) ); }

For larger jobs, move to a queue. A worker pool reading from Redis or SQS gives you natural backpressure and lets you tune concurrency without redeploying.

Prefer async rendering for bulk jobs

If your API supports async rendering, use it. Synchronous calls hold connections open, consume request slots, and amplify rate-limit pressure. Async submission typically counts as one lightweight request plus a webhook:

ts
const job = await kamy.documents.renderAsync({ template: "invoice", data: invoice, webhook: "https://api.myapp.com/kamy/callback", });

Your worker is free as soon as the job is queued. The webhook delivers the finished PDF URL. This pattern alone can cut your effective request rate by 10x on long-running renders.

Managing quota across tenants

If you're a B2B SaaS reselling document generation, upstream quota becomes your problem. Three practices worth stealing:

  • Per-tenant counters: track documents per customer in your own database. Enforce internal limits before you hit the upstream API.
  • Cost signaling: expose remaining quota in your customer dashboard. Surprises create tickets.
  • Graceful degradation: when you're near the ceiling, defer non-critical jobs (reports, batch exports) and prioritize interactive ones (receipts, confirmations).

A simple guard in the request path:

ts
async function enqueueRender(tenantId: string, payload: R