Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, quotas, and backpressure when generating PDFs and other documents through third-party APIs.

rate-limitingapi-designpdfnode.jsbackend

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:

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

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

  1. Respect Retry-After when the server sends it.
  2. Use exponential backoff with jitter for anything else.

Here's a small helper:

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

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

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