Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, retries, and quotas when generating PDFs and other documents at scale.

rate-limitingapipdfbackendnode.js

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 images, and serialize a multi-megabyte file — orders of magnitude more work than a typical JSON endpoint. That's why nearly every document API enforces strict rate limits and quotas, and why your integration needs to handle them deliberately. Treat 429s as a first-class concern, not an edge case.

This post covers the patterns that actually matter when you're generating documents programmatically: reading rate-limit signals, implementing backoff, batching wisely, and tracking quota consumption before your monthly invoice surprises you.

The two limits you'll hit

Most document APIs enforce two separate constraints:

  1. Rate limits — short-window throughput, e.g. "60 requests per minute" or "5 concurrent renders."
  2. Quotas — long-window allowances tied to your plan, e.g. "10,000 documents/month."

Rate limits protect the provider's infrastructure. Quotas protect their billing model. They fail in different ways and need different handling. A 429 from a rate limit is usually transient — retry and you're fine. A 429 (or 402) from a quota means you're done until the next billing cycle or a plan upgrade.

Reading the response headers

A well-designed API tells you exactly where you stand on every response. Kamy, for example, returns headers like:

http
HTTP/1.1 200 OK X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 X-RateLimit-Reset: 1730412000 X-Quota-Limit: 10000 X-Quota-Remaining: 8423 X-Quota-Reset: 1733004000

Don't ignore these. Log them, expose them on a dashboard, or at minimum trigger alerts when X-Quota-Remaining drops below a threshold. A simple wrapper that surfaces this data is worth the 20 minutes it takes to write:

ts
import { kamy } from "@kamydev/sdk"; async function renderWithMetrics(payload: RenderPayload) { const response = await kamy.documents.renderRaw(payload); const remaining = Number(response.headers.get("x-quota-remaining") ?? 0); const limit = Number(response.headers.get("x-quota-limit") ?? 0); if (limit > 0 && remaining / limit < 0.1) { logger.warn("Document quota below 10%", { remaining, limit }); } return response; }

Handling 429 correctly

The single biggest mistake is retrying immediately on a 429. That just makes things worse. The correct behavior is:

  1. Read the Retry-After header (seconds or HTTP date).
  2. Wait at least that long.
  3. Add jitter so multiple workers don't synchronize their retries.
  4. Cap total retries — don't loop forever.

Here's a minimal but production-ready retry helper:

ts
async function withRetry<T>( fn: () => Promise<Response>, maxRetries = 5, ): Promise<Response> { let attempt = 0; while (true) { const res = await fn(); if (res.status !== 429 || attempt >= maxRetries) { return res; } const retryAfter = Number(res.headers.get("retry-after") ?? 1); const jitter = Math.random() * 0.5; const delayMs = (retryAfter + jitter) * 1000 * Math.pow(2, attempt); await new Promise((r) => setTimeout(r, delayMs)); attempt++; } }

Note the exponential factor on top of Retry-After. The server's hint is a minimum, not a maximum — if you keep hitting it, back off harder.

Concurrency control on your side

Even if the API allows 60 requests per minute, firing all 60 in the first second is a bad idea. Smooth the load with a concurrency limiter. Libraries like p-limit make this trivial:

ts
import pLimit from "p-limit"; import { kamy } from "@kamydev/sdk"; const limit = pLimit(5); // max 5 concurrent renders async function renderBatch(invoices: Invoice[]) { return Promise.all( invoices.map((invoice) => limit(() => kamy.documents.render({ template: "invoice", data: invoice, }), ), ), ); }

Pick a concurrency number that matches your plan's concurrent-render allowance, not your total RPM. For bulk jobs (think monthly statement runs), this is the difference between finishing in 10 minutes and getting throttled into oblivion.

Async rendering for bulk workloads

If you're rendering thousands of documents, synchronous request-per-document is the wrong model. Use the async/job endpoint if your provider offers one:

ts
const job = await kamy.jobs.create({ template: "statement", items: customers.map((c) => ({ data: c })), webhook: "https://api.example.com/hooks/kamy", });

The provider queues the work, respects its own internal limits, and notifies you when done. Your quota still gets consumed, but you avoid rate-limit thrash entirely. This is also cheaper to operate on your side: no long-lived HTTP connections, no retry storms.

Tracking quota in your own system

Don't rely solely on the provider's dashboard. Mirror the data:

ts
await db.quotaUsage.upsert({ where: { date_provider: { date: today, provider: "kamy" } }, update: { documents: { increment: 1 } }, create: { date: today, provider: "kamy", documents: 1 },