Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, retries, and quota budgeting when generating PDFs and documents at scale through third-party APIs.

rate-limitingapi-designpdfbackendnode.js

Rate Limits and Quota Management in Document Generation APIs

Document generation looks cheap on paper: send some data, get a PDF back. In production it's one of the most expensive endpoints your app will hit. Rendering runs headless browsers, loads fonts, fetches images, and writes bytes. That's why every serious document API — Kamy included — enforces rate limits and quotas. If you don't handle them well, your invoice job silently drops requests on the busiest day of the month.

This post covers the patterns that actually work: reading rate limit headers, backing off correctly, batching, and budgeting a monthly quota across services.

The two limits you'll hit

Most document APIs enforce two independent controls:

  1. Rate limits — requests per second/minute. Prevents burst overload.
  2. Quotas — total documents per billing period. Enforces your plan.

You need to handle both, and they fail differently. Rate limits are transient (retry works). Quotas are terminal until reset (retry doesn't help).

Read the headers, always

A well-designed API tells you exactly where you stand. Kamy returns these on every response:

http
HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 47 X-RateLimit-Reset: 1730000000 X-Quota-Limit: 10000 X-Quota-Remaining: 8421 X-Quota-Reset: 1732492800

Log these. Even a simple metric on X-Quota-Remaining will save you from a 3 AM page on the last day of the month.

ts
function trackLimits(res: Response) { const remaining = Number(res.headers.get("x-ratelimit-remaining")); const quotaLeft = Number(res.headers.get("x-quota-remaining")); metrics.gauge("docs.rate_limit.remaining", remaining); metrics.gauge("docs.quota.remaining", quotaLeft); if (quotaLeft < 500) { logger.warn("Document quota approaching limit", { quotaLeft }); } }

Handling 429 correctly

When you get rate limited, you'll see a 429 Too Many Requests with a Retry-After header. Respect it. Don't retry immediately, and don't hardcode your own delay.

ts
async function renderWithRetry(payload: RenderPayload, attempt = 0): Promise<Buffer> { 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) { if (attempt >= 5) throw new Error("Rate limit retries exhausted"); const retryAfter = Number(res.headers.get("retry-after") ?? 1); const jitter = Math.random() * 250; await new Promise(r => setTimeout(r, retryAfter * 1000 + jitter)); return renderWithRetry(payload, attempt + 1); } if (res.status === 402) { throw new QuotaExceededError("Monthly document quota exhausted"); } if (!res.ok) throw new Error(`Render failed: ${res.status}`); return Buffer.from(await res.arrayBuffer()); }

Two things worth calling out:

  • Jitter matters. If 200 workers all retry at exactly Retry-After seconds, you'll re-thundering-herd the API. Random jitter spreads them out.
  • Quota errors are different. A 402 Payment Required (or 403 with a quota code) is not something to retry. Fail fast and route the job to a dead-letter queue for human review.

Client-side rate limiting

Waiting for a 429 is reactive. The better pattern is to stay under the limit on your side using a token bucket. This becomes essential when you have multiple workers hitting the same API key.

ts
import Bottleneck from "bottleneck"; const limiter = new Bottleneck({ reservoir: 60, // 60 requests reservoirRefreshAmount: 60, reservoirRefreshInterval: 60_000, // per 60 seconds maxConcurrent: 10, }); export function renderPdf(payload: RenderPayload) { return limiter.schedule(() => renderWithRetry(payload)); }

If you're running multiple pods, use a distributed limiter backed by Redis (Bottleneck supports this natively) so all workers share the same budget.

Batching and async rendering

If you need to generate hundreds or thousands of documents in a burst — end-of-month statements, weekly reports — don't fan out synchronous calls. Use the batch or async endpoint if the API offers one:

ts
const job = await kamy.batches.create({ template: "statement-q4", items: customers.map(c => ({ id: c.id, data: { customer: c, transactions: c.transactions }, })), webhook: "https://api.myapp.com/hooks/kamy", });

A single batch call counts as one rate-limited request, and the provider can schedule the render workload efficiently on their side. You get a webhook when each document is ready, or when the batch completes.

Budgeting your monthly quota

If you're on a 10,000 documents/month plan and you burn 8,000 in the first week, you have a problem. Track burn rate:

ts
function projectedMonthlyUsage(usedSoFar: number, dayOfMonth: number, daysInMonth: number) { return Math.round((usedSoFar / dayOfM