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:
httpHTTP/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:
tsasync 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:
tsasync 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-Afterwhen 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.
tsimport 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:
tsconst 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:
tsasync function enqueueRender(tenantId: string, payload: R