Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, backoff, and quotas when generating PDFs and documents through an API, with patterns you can drop into production.

pdfrate-limitingapinode.jsbackend

Rate Limits and Quota Management in Document Generation APIs

Document generation is deceptively expensive. Rendering a PDF involves spawning a headless browser or a layout engine, loading fonts, resolving images, and serializing output. That's why every serious document API — Kamy included — enforces rate limits and quotas. If you treat those limits as an afterthought, your invoice job will fail on the first busy Monday of the month.

This post covers what rate limits in document APIs typically look like, how to detect and react to them, and patterns for staying inside your quota without manually babysitting jobs.

The two things people call "rate limits"

In API docs, "rate limit" usually bundles two different concepts:

  • Request rate: how many calls per second/minute you can make (e.g., 20 requests per second).
  • Quota: how many documents you can generate per billing period (e.g., 50,000 PDFs/month).

They fail differently. Hitting the request rate usually returns 429 Too Many Requests and is recoverable with backoff. Blowing your monthly quota returns 402 Payment Required or 403 Forbidden and requires a plan change or waiting until the next cycle.

Design your client to handle both.

Reading rate limit headers

Most document APIs expose their state through response headers. A typical response from Kamy looks like this:

http
HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1731000000 X-Quota-Limit: 50000 X-Quota-Remaining: 48213 X-Quota-Reset: 1733616000

Don't just log these — use them. If X-RateLimit-Remaining drops below a threshold, your worker can slow down proactively instead of waiting for a 429.

ts
function shouldThrottle(headers: Headers): boolean { const remaining = Number(headers.get("x-ratelimit-remaining") ?? "999"); const limit = Number(headers.get("x-ratelimit-limit") ?? "1"); return remaining / limit < 0.1; }

Handling 429 with exponential backoff and jitter

When you do get a 429, respect the Retry-After header if it's present. If it isn't, back off exponentially and add jitter so you don't synchronize retries across workers.

ts
async function renderWithRetry(payload: unknown, maxAttempts = 5) { for (let attempt = 0; attempt < maxAttempts; attempt++) { 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), }); if (res.status !== 429) return res; const retryAfter = Number(res.headers.get("retry-after") ?? "0"); const base = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500; const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, base + jitter)); } throw new Error("Rate limit retries exhausted"); }

Three things to notice: we only retry on 429 (not on 4xx or 5xx without more context), we honor Retry-After if provided, and we cap attempts so a persistently rate-limited job doesn't loop forever.

Client-side throttling with a token bucket

If you're generating thousands of documents in a batch — statements, reports, contracts — you want to shape your traffic before it hits the API. A token bucket is the standard tool.

ts
class TokenBucket { private tokens: number; private lastRefill = Date.now(); constructor(private capacity: number, private refillPerSec: number) { this.tokens = capacity; } async take(): Promise<void> { while (true) { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec); this.lastRefill = now; if (this.tokens >= 1) { this.tokens -= 1; return; } const waitMs = ((1 - this.tokens) / this.refillPerSec) * 1000; await new Promise((r) => setTimeout(r, waitMs)); } } } const bucket = new TokenBucket(60, 10); // 10/sec, burst of 60 for (const invoice of invoices) { await bucket.take(); queue.enqueue(() => renderWithRetry(invoice)); }

Pair this with a bounded concurrency queue (like p-limit) and you'll stay under the API's per-second limit without ever triggering a 429.

Batch endpoints and async jobs

If your API supports a batch or async endpoint, use it. A single call that renders 500 documents counts as one request against your rate limit even though it produces 500 documents against your quota.

ts
const job = await fetch("https://api.kamy.dev/v1/documents/batch", { method: "POST", headers: { "Authorization": `Bearer ${process.env.KAMY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ template: "statement", items: statements, // array of per-document data webhook: "https://app.example.com/webhooks/kamy", }), }).then((r) => r.json()); console.log("Job queued:", job.id);

Then let a webhook notify you when it's done.