amruthpillai/reactive-resume · warning · ORPCError

RATE_LIMIT_EXCEEDED

RATE_LIMIT_EXCEEDED

Error message

Public resume rendering rate limit exceeded.

What it means

A per-(trustedClient,resumeId) token bucket (default capacity 6, full refill over 60s) guards public resume rendering. consume() throws RATE_LIMIT_EXCEEDED (HTTP 429) when fewer than 1 token is available. The limiter is in-memory and process-local, so each server process has its own bucket.

Source

Thrown at packages/api/src/features/resume/public-render-rate-limit.ts:41

	const capacity = options.capacity ?? 6;
	const refillWindowMs = options.refillWindowMs ?? 60_000;
	const now = options.now ?? Date.now;
	if (!Number.isInteger(capacity) || capacity <= 0 || !Number.isFinite(refillWindowMs) || refillWindowMs <= 0) {
		throw new Error("Public render token bucket requires positive finite limits");
	}
	const buckets = new Map<string, Bucket>();

	return {
		consume(input) {
			const currentTime = now();
			const trustedClient = input.trustedClient.trim() || "unknown";
			const key = `${trustedClient}:${input.resumeId}`;
			const previous = buckets.get(key) ?? { tokens: capacity, updatedAt: currentTime };
			const elapsed = Math.max(0, currentTime - previous.updatedAt);
			const tokens = Math.min(capacity, previous.tokens + (elapsed * capacity) / refillWindowMs);

			if (tokens < 1) {
				throw new ORPCError("RATE_LIMIT_EXCEEDED", {
					status: 429,
					message: "Public resume rendering rate limit exceeded.",
				});
			}

			buckets.delete(key);
			buckets.set(key, { tokens: tokens - 1, updatedAt: currentTime });
			if (buckets.size <= MAX_BUCKETS) return;

			for (const [candidate, bucket] of buckets) {
				if (currentTime - bucket.updatedAt >= refillWindowMs) buckets.delete(candidate);
			}
			if (buckets.size > MAX_BUCKETS) buckets.delete(buckets.keys().next().value as string);
		},
	};
}

export const publicRenderRateLimiter = createPublicRenderRateLimiter();

View on GitHub (pinned to 3a5b12e2a4)

Solutions

  1. Throttle client-side: cache the rendered PDF and avoid re-requesting within the window.
  2. If legitimately raising the limit, adjust capacity/refillWindowMs via createPublicRenderRateLimiter options (server-side).
  3. Prefer the client-side projection path so the server fallback render is never hit.
Defensive patterns

Strategy: retry

Validate before calling

const RENDER_BUDGET = 6; // capacity per 60s window per (client,resume)
function canRender(state, resumeId) {
  const now = Date.now();
  const used = state.counts.get(`${state.client}:${resumeId}`) ?? { n: 0, t: now };
  const elapsed = now - used.t;
  const tokens = Math.min(RENDER_BUDGET, used.n + (elapsed * RENDER_BUDGET) / 60000);
  return tokens >= 1;
}

Try / catch

async function renderWithBackoff(fn) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try { return await fn(); }
    catch (e) {
      if (e.code !== 'RATE_LIMIT_EXCEEDED') throw e;
      await new Promise(r => setTimeout(r, (attempt + 1) * 10000)); // ~1 token / 10s
    }
  }
  throw new Error('Public render rate limit exhausted');
}

Prevention

When it happens

Trigger: More than 6 public PDF renders of the same resume from the same trustedClient identifier within ~60 seconds, or sustained rendering faster than the refill rate (1 token per ~10s).

Common situations: A page with aggressive auto-refresh, a bot crawling public resumes, multiple users behind a shared client identifier, or a load test hammering the fallback endpoint.

Related errors


AI-assisted analysis of amruthpillai/reactive-resume@3a5b12e2a4 (2026-08-12). Data as JSON: /api/errors/d335ba6266fc8a1f. Report an issue: GitHub.