Rate Limits and Quota Management in Document Generation APIs
Document generation is deceptively expensive. A single PDF render can involve HTML parsing, headless browser instances, font loading, and image processing. That's why almost every serious document API — Kamy included — enforces rate limits and quotas. If you're integrating one into a production system, understanding how these limits work (and how to gracefully live within them) will save you from 429s at 3 a.m.
This post covers the mechanics of rate limits in document generation APIs, patterns for client-side backpressure, and how to design your own systems around quotas.
Why document APIs are rate-limited
Unlike a typical REST call that returns JSON, a PDF render might tie up CPU and memory for hundreds of milliseconds. Providers protect their infrastructure with two overlapping controls:
- Rate limits: short-window caps like "60 requests per minute per API key."
- Quotas: longer-window caps like "10,000 documents per month" tied to your plan.
Rate limits protect the system from bursts. Quotas enforce commercial boundaries. You need to handle both, and they fail differently.
Reading rate limit headers
Most APIs return standardized headers. Kamy, for example, returns:
httpHTTP/1.1 200 OK X-RateLimit-Limit: 120 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1732041600 X-Quota-Limit: 10000 X-Quota-Remaining: 4231 X-Quota-Reset: 1733011200
Before firing off high-volume batches, inspect these headers. A cheap wrapper around your HTTP client can expose them to your application logic:
tsasync function renderPdf(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), }); const rateLimit = { remaining: Number(res.headers.get("X-RateLimit-Remaining")), reset: Number(res.headers.get("X-RateLimit-Reset")), }; if (res.status === 429) { const retryAfter = Number(res.headers.get("Retry-After") ?? 1); throw new RateLimitError(retryAfter); } return { data: await res.json(), rateLimit }; }
Retrying without making things worse
The naive retry loop is the fastest way to turn a temporary spike into an outage. Two rules:
- Respect
Retry-Afterwhen the server sends it. - Use exponential backoff with jitter for anything else.
Here's a small helper:
tsasync function withRetry<T>(fn: () => Promise<T>, maxAttempts = 5): Promise<T> { let attempt = 0; while (true) { try { return await fn(); } catch (err) { attempt++; if (attempt >= maxAttempts) throw err; const base = err instanceof RateLimitError ? err.retryAfter * 1000 : 250 * 2 ** attempt; const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, base + jitter)); } } }
The jitter matters. If ten workers all retry at exactly t + 1000ms, you've just recreated the burst that got you throttled in the first place.
Client-side rate limiting
Rather than reacting to 429s, cap yourself below the server's limit. A token bucket is the standard tool. Libraries like bottleneck handle this cleanly:
tsimport Bottleneck from "bottleneck"; const limiter = new Bottleneck({ maxConcurrent: 5, minTime: 100, // 10 requests/sec }); async function generateInvoice(invoice: Invoice) { return limiter.schedule(() => renderPdf({ template: "invoice", data: invoice, })); }
Set minTime so your steady-state rate stays comfortably under the published limit — 70–80% is a good target. That leaves headroom for retries and other services sharing the key.
Handling quota exhaustion
Quotas are different from rate limits: waiting won't fix them. When X-Quota-Remaining hits zero, requests fail until the reset date or a plan upgrade. Your system should:
- Alert early. Fire a warning when you cross 80% of your monthly quota, not when you hit 100%.
- Degrade gracefully. For non-critical documents (e.g. optional receipts), skip generation and queue them for later. For critical documents (contracts, invoices), fail loudly.
- Separate keys by workload. Use distinct API keys for user-facing rendering and background jobs. That way a runaway batch job doesn't block real-time flows.
A basic quota monitor looks like this:
tsfunction checkQuota(headers: Headers) { const remaining = Number(headers.get("X-Quota-Remaining")); const limit = Number(headers.get("X-Quota-Limit")); const ratio = remaining / limit; if (ratio < 0.2) { metrics.gauge("kamy.quota.low", ratio); if (ratio < 0.05) alerting.page("Kamy quota nearly exhausted"); } }
Batching and async rendering
If you generate documents in bulk (nightly reports, monthly statements), synchronous rendering wastes both time and rate limit budget. Most document APIs offer an async or batch mode. With Kamy: