VernLLMVernLLM
Guides

Tuning Rate Limits

Sizing rateLimit against a real provider plan, and telling a local wait apart from a provider 429

Rate Limiting covers the rateLimit option itself, the three buckets, queueing, and the events it emits. This guide walks through putting it to use: sizing it against numbers a provider actually gives you, sharing one budget across several routes, and deciding what to do when a call gives up waiting.

Start from your plan's real numbers, not a guess

Every major provider dashboard publishes requests-per-minute and tokens-per-minute limits per model per tier. Set rateLimit a little under those, not at them exactly, so VernLLM's own request overhead and estimation slack still land you under the real ceiling:

rate-limiting-plan-numbers.ts
// Provider dashboard says: gpt-4o, tier 2 → 5,000 RPM / 800,000 TPM
const llm = new VernLLM({
  client: openai,
  model: 'gpt-4o',
  rateLimit: {
    requestsPerMinute: 4_500, // ~10% headroom
    tokensPerMinute: 720_000, // ~10% headroom
    maxConcurrent: 100,
  },
});

maxConcurrent isn't published anywhere, it's not a rate the provider enforces, it's a knob for your own outbound connection pool and downstream memory. Size it against how many concurrent requests your process (or the connection pool underneath your HTTP client) can actually sustain, not the provider's RPM number.

Headroom matters more for tokensPerMinute than requestsPerMinute. The default token estimate is a rough chars / 4 heuristic (see Estimating tokens), so a limit set right at the provider's real ceiling leaves no room for the estimate running high on a given call.

One instance, one budget, many callers

rateLimit is per VernLLM instance, not global. If several routes or background jobs in your app share one provider API key, route them all through the same instance so they share one budget, instead of creating a new VernLLM (and therefore a fresh, unaware set of buckets) per call site:

rate-limiting-shared-instance.ts
// shared.ts, imported everywhere that talks to this provider
export const llm = new VernLLM({
  client: openai,
  model: 'gpt-4o',
  rateLimit: { requestsPerMinute: 4_500, tokensPerMinute: 720_000, maxConcurrent: 100 },
});
rate-limiting-route-usage.ts
import { llm } from './shared.js';

export async function summarizeHandler(req: Request) {
  return llm.call({ userContent: req.body.text });
}

Two separate VernLLM instances pointed at the same provider account have two separate, unaware buckets between them, and can together exceed the account's real limit even though each instance individually stays under its own configured number.

If you run multiple providers behind one logical service, fallback targets each get their own rateLimit, sized against that provider's own numbers; it's never inherited from the primary. See

Provider Fallback for declaring several targets on one instance.

Choosing maxQueueMs for your call site

maxQueueMs is the most important knob to actually think about, because its right value depends on what's waiting on the other end of the call:

rate-limiting-queue-ms-by-context.ts
// An interactive chat request: a user is staring at a spinner. Fail fast
// and show them something rather than let them wait out a long queue.
const chatLlm = new VernLLM({
  client: openai,
  model: 'gpt-4o',
  rateLimit: { requestsPerMinute: 500, maxQueueMs: 3_000 },
});

// A background batch job processing a queue of documents overnight.
// Nobody's watching a spinner; let it wait out the rate limit instead
// of failing and needing its own retry-with-backoff logic bolted on.
const batchLlm = new VernLLM({
  client: openai,
  model: 'gpt-4o-mini',
  rateLimit: { requestsPerMinute: 500, maxQueueMs: 0 }, // wait indefinitely
});

For the interactive case, catch the local rate-limit failure and degrade gracefully instead of surfacing a raw error. type: 'rate_limited' covers only VernLLM's own local rateLimit queue rejecting the call; a provider's own HTTP 429 is type: 'api' with code: 'provider_rate_limited' instead. rate_limit_queue_full/rate_limit_queue_timeout specifically mean the local queue gave up:

rate-limiting-graceful-degrade.ts
import { isLLMError } from 'vern-llm';

try {
  return await chatLlm.call({ userContent: message });
} catch (err) {
  if (
    isLLMError(err) &&
    (err.code === 'rate_limit_queue_full' || err.code === 'rate_limit_queue_timeout')
  ) {
    return { message: "We're a little busy right now, please try again in a moment." };
  }
  throw err;
}

Don't retry a rate_limit_queue_timeout error yourself in a tight loop. The call already waited up to maxQueueMs for capacity that never came; retrying it immediately just requeues it behind the same limit. If you want another attempt, wait a meaningful amount of time first, or size maxQueueMs longer instead of handling it at the call site.

Telling a local wait apart from a real provider 429

Both a local queue giving up and an actual provider rate limit surface as errors, but they mean different things operationally: one says your own configured ceiling was too tight, the other says the provider itself pushed back.

rate-limiting-distinguish-in-logging.ts
import { isLLMError } from 'vern-llm';

try {
  await llm.call({ userContent: message });
} catch (err) {
  if (
    isLLMError(err) &&
    (err.code === 'rate_limit_queue_full' || err.code === 'rate_limit_queue_timeout')
  ) {
    console.warn('rateLimit queue gave up, consider raising the configured limits');
  } else if (isLLMError(err) && err.code === 'provider_rate_limited') {
    console.warn('provider itself rate limited this request');
  }
  throw err;
}

A rate_limit_queue_full or rate_limit_queue_timeout error in your logs is a signal that your configured rateLimit numbers are tighter than your real traffic, worth revisiting rather than something to retry away. A provider_rate_limited error, on the other hand, already went through the normal retry and Retry-After handling before you ever see it; seeing one at all after retries were exhausted means the provider is sustained-throttling you past what backoff alone recovers from.

Watching queue pressure before it becomes failures

onEvent's rate_limited event fires on every attempt that had to wait, before any of those waits turn into an actual rate_limit_queue_full or rate_limit_queue_timeout failure. Wiring it into your existing metrics gives you a leading indicator, so you can raise a limit (or investigate a traffic spike) before callers start seeing errors:

rate-limiting-metrics.ts
const llm = new VernLLM({
  client: openai,
  model: 'gpt-4o',
  rateLimit: { requestsPerMinute: 4_500, tokensPerMinute: 720_000, maxConcurrent: 100 },
  onEvent: (event) => {
    if (event.kind === 'rate_limited') {
      metrics.observe('llm.rate_limit.wait_ms', event.waitedMs, {
        provider: event.provider,
        reason: event.reason, // 'concurrency' | 'rpm' | 'tpm'
      });
    }
  },
});

A reason that's consistently 'tpm' points at raising tokensPerMinute or tightening estimateTokens. A reason that's consistently 'concurrency' points at your own connection pool, not the provider, being the actual bottleneck. See Observing waits for the full event shape.

Sizing tokensPerMinute for a mixed workload

If different call sites through the same instance send very different amounts of text (a short classification prompt versus a long document summarization), the default chars / 4 estimate is usually fine, both directions self-correct against real usage once each call completes. But if one call site dominates your traffic and its prompts have a very different token density than chars / 4 assumes (dense code, non-English text, heavily repeated tokens), a custom estimateTokens pays off:

rate-limiting-estimate-for-mixed-workload.ts
import { encode } from 'gpt-tokenizer';

const llm = new VernLLM({
  client: openai,
  model: 'gpt-4o',
  rateLimit: {
    tokensPerMinute: 720_000,
    estimateTokens: (request) => {
      const promptTokens = request.messages.reduce(
        (sum, m) => sum + encode(typeof m.content === 'string' ? m.content : '').length,
        0,
      );
      return promptTokens + (request.max_tokens ?? 0);
    },
  },
});

See Estimating tokens for exactly how the estimate and the post-call reconciliation interact.

On this page