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:
- Rate limits — requests per second/minute. Prevents burst overload.
- 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:
httpHTTP/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.
tsfunction 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.
tsasync 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-Afterseconds, you'll re-thundering-herd the API. Random jitter spreads them out. - Quota errors are different. A
402 Payment Required(or403with 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.
tsimport 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:
tsconst 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:
tsfunction projectedMonthlyUsage(usedSoFar: number, dayOfMonth: number, daysInMonth: number) { return Math.round((usedSoFar / dayOfM