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 assets, and paginate a 50-page report. Multiply that by a thousand invoices at end-of-month, and you understand why every serious document generation API enforces rate limits. If you're integrating one — Kamy included — you need a strategy for staying under the ceiling without dropping jobs.
This post walks through how rate limits typically work in document APIs, how to detect them in your client code, and how to design a system that absorbs bursts without exploding.
How document APIs typically limit you
Most document generation APIs apply at least two limits:
- Requests per second (RPS) — protects the rendering infrastructure from spikes.
- Monthly quota — tied to your plan; counts total documents rendered.
Some APIs add a third tier, like concurrent renders or per-template throughput. The response usually carries headers that tell you exactly where you stand:
httpHTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1730800000 X-Quota-Limit: 10000 X-Quota-Used: 3847
When you exceed a limit, you get 429 Too Many Requests, typically with a Retry-After header telling you how long to wait.
Reading the headers in your client
Don't ignore these headers. Even on successful responses, they tell you how close you are to the cliff. A simple wrapper:
tsimport { kamy } from "@kamydev/sdk"; async function renderWithTelemetry(payload: RenderPayload) { const response = await kamy.documents.render(payload); const remaining = Number(response.headers["x-ratelimit-remaining"]); const reset = Number(response.headers["x-ratelimit-reset"]); if (remaining < 5) { console.warn(`Rate limit nearly exhausted. Resets at ${new Date(reset * 1000).toISOString()}`); } return response; }
Log these values to your metrics backend. You want a dashboard that shows quota burn-down over the month, not a 3 a.m. page when you hit the wall.
Handling 429s: exponential backoff with jitter
The naive approach is to retry immediately. Don't. If you have ten workers all retrying at once, you'll thunder-herd the API and just collect more 429s.
Use exponential backoff with jitter:
tsasync function renderWithRetry(payload: RenderPayload, maxRetries = 5) { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await kamy.documents.render(payload); } catch (err: any) { if (err.status !== 429 || attempt === maxRetries) throw err; const retryAfter = Number(err.headers?.["retry-after"]) || 0; const backoff = retryAfter > 0 ? retryAfter * 1000 : Math.min(1000 * 2 ** attempt, 30_000); const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, backoff + jitter)); } } }
Two rules: respect Retry-After when the server gives it to you, and always add jitter. Otherwise, all your retrying clients sync up.
Client-side rate limiting
The cleanest fix is to never hit 429 in the first place. Throttle at the client. A token bucket works well:
tsclass TokenBucket { private tokens: number; private lastRefill = Date.now(); constructor(private capacity: number, private refillPerSec: number) { this.tokens = capacity; } async take(): Promise<void> { while (true) { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return; } await new Promise((r) => setTimeout(r, 50)); } } private refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec); this.lastRefill = now; } } const bucket = new TokenBucket(60, 60); // 60 RPS, burst of 60 await bucket.take(); await kamy.documents.render(payload);
Set your bucket slightly below the published limit. If the API allows 60 RPS, configure 55. That headroom absorbs clock skew and microbursts.
Queues for bursty workloads
If you generate documents from a batch job — say, monthly invoices for 50,000 customers — fronting the API with a job queue is non-negotiable. The queue decouples your input rate from the API's output rate.
tsimport { Queue, Worker } from "bullmq"; const queue = new Queue("invoices"); // Producer: enqueue 50k jobs in seconds for (const customer of customers) { await queue.add("render", { customerId: customer.id }); } // Worker: throttled consumer new Worker("invoices", async (job) => { await bucket.take(); return renderWithRetry({ template: "invoice", data: job.data, }); }, { concurrency: 10 });
Now your producer can dump 50k jobs in a few seconds, and the worker drains them at a rate the API can absorb. If something fails transiently, BullMQ retries with backoff.
Tracking monthly quota
RPS limits reset every second. Monthly quotas don't. Hitting your quota mid-month is much worse than getting throttled — it stops generation