Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, retries, and quota tracking when generating PDFs and documents programmatically at scale.

apirate-limitingpdfbackendreliability

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:

  1. Requests per second (RPS) — protects the rendering infrastructure from spikes.
  2. 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:

http
HTTP/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:

ts
import { 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:

ts
async 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:

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) { 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.

ts
import { 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