Skip to content

Limit Model (Rate Limiting)

@rejelly/limit-model provides model rate-limiting middleware, mountable on any ModelAdapter via augmentModel:

  • withSimpleLimit(options): Simplified wrapper for rpm / tpm / concurrency, quick to set up.
  • withLimit(options): Rule-level, each rule has its own key, supports multi-dimensional combinations and pluggable Store.

Rate limiting uses a fast-fail strategy: exceeding the limit throws an error directly (error types), no queuing, no "rate-limit waiting" events emitted.

withSimpleLimit(options)

typescript
import { augmentModel } from '@rejelly/core';
import { withSimpleLimit } from '@rejelly/limit-model';

const simpleLimitedModel = augmentModel(baseModel, [
  withSimpleLimit({
    rpm: 60,
    tpm: 90000,
    concurrency: 5,
    key: 'my-model',
    store: undefined,  // if omitted, creates a new MemoryStore for this middleware
  }),
]);

Options (SimpleLimitOptions):

PropertyTypeRequiredDefaultDescription
rpmnumber-Max requests per minute
tpmnumber-Max tokens per minute
concurrencynumber-Max concurrency
keystring"default-model"Unique identifier for rate-limit scope, prevents rule overlap across models
storeRateLimitStoreNew independent MemoryStoreCustom storage (e.g., RedisStore); if omitted, each middleware instance creates its own in-memory store

At least one of rpm, tpm, or concurrency must be provided. Internally maps to corresponding Rules (key format: ${key}:rpm) and routes through withLimit.

withLimit(options)

Rule-level rate limiting: each rule has its own key, and a single request can be constrained by multiple rules simultaneously (e.g., tenant-global concurrency + per-model RPM).

typescript
import { augmentModel } from '@rejelly/core';
import { withLimit, MemoryStore, RedisStore } from '@rejelly/limit-model';

const store = new MemoryStore();  // or new RedisStore(redisClient)
const ruleLimitedModel = augmentModel(baseModel, [
  withLimit({
    store,
    rules: [
      // Global gpt4 level
      { type: 'concurrency', key: 'gpt4', limit: 100 },
      { type: 'request', key: 'gpt4', limit: 500, windowMs: 60000 },
      { type: 'token', key: 'gpt4', limit: 500000, windowMs: 60000 },
      // Tenant A dimension
      { type: 'concurrency', key: 'tenant:A', limit: 10 },
      { type: 'request', key: 'tenant:A:gpt4', limit: 50, windowMs: 60000 },
      { type: 'token', key: 'tenant:A:gpt4', limit: 90000, windowMs: 60000 },
    ],
  }),
]);

Options (WithLimitOptions):

PropertyTypeRequiredDefaultDescription
storeRateLimitStore-Injected storage (MemoryStore / RedisStore / custom implementation)
rulesRateLimitRule[]-Array of rate-limit rules, checked in array order — first exceeded limit causes failure
calculatePreDeduct(messages) => numberInput text length / 4Token pre-deduction estimation: pre-deducts before the request, refunds excess after streaming completes based on actual usage
retryAfterBufferMsnumber100Buffer added to the store's returned retryAfterMs, preventing clients from hitting the limit again when retrying exactly on time

Rule Types:

TypeDescriptionFields
ConcurrencyRuleConcurrency counttype: 'concurrency', key, limit (no windowMs)
RequestRuleRequests per minute (RPM)type: 'request', key, limit, windowMs
TokenRuleTokens per minute (TPM)type: 'token', key, limit, windowMs

Store

  • MemoryStore: Single-process in-memory, suitable for development or single-process deployment.
  • RedisStore: Production use, requires a RedisLike client (e.g., ioredis). Concurrency uses ZSET (member=requestId, score=expiration time), self-healing via ZREMRANGEBYSCORE in Lua after a process kill. Token/Request use Hash buckets. Key format is {hashTag}:key:typehash tag determines the Redis Cluster slot: defaults to limit-model so all keys share the same slot, and any rule combination can be checked atomically within a single Lua script.

⚠️ MemoryStore is not suitable for multi-process

MemoryStore data resides in-process memory and is not shared across processes. In PM2 multi-process / cluster mode, multi-instance deployments, Serverless multi-instance scenarios, each process counts independently — rate limits are effectively multiplied (e.g., 4 workers = 4x effective concurrency), making rate limiting ineffective. Such deployments must use RedisStore (or another cross-process shared Store).

⚠️ RedisStore has a single-point bottleneck

Rate limiting implementation note: RedisStore relies internally on Redis Lua scripts + same-slot keys for consistency. With the default fixed hash tag, all rate-limit traffic concentrates on a single node (Lua executes single-threaded, equivalent to a single core). Do not use in very high QPS scenarios (typically QPS > 10K). In multi-tenant scenarios, use the hashTag function to distribute tenants across different slots (see options and example below), at the cost that cross-tenant global rules cannot be mixed within the same check.

RedisStore Options (RedisStoreOptions):

PropertyTypeRequiredDefaultDescription
maxConcurrencyTimeoutMsnumber60000Max hold time for a concurrency slot; cleaned up by Lua on timeout (prevents deadlock after process kill)
prefixstring-Key prefix (does not participate in hash tag, does not affect slot)
hashTagstring | (rule) => string"limit-model"Redis Cluster hash tag. string: entire store shares one tag (e.g., isolate deployments). function: compute tag per rule (recommend deriving from rule.key only, e.g., tenant segment), distributing tenants across slots. All rules in the same check must compute to the same tag — mixing tags throws an error before sending to Redis
typescript
// Multi-tenant hot-spot distribution: derive hash tag from the tenant segment of rule.key
// (the split rule must match your key naming convention)
const store = new RedisStore(redisClient, {
  hashTag: (rule) => rule.key.split(':')[0],
});
// Same check, same tenant's two rules — same tag, same slot, atomic ✓
//   { type: 'concurrency', key: 'tenantA' }    → {tenantA}:tenantA:concurrency
//   { type: 'request', key: 'tenantA:gpt-4' }  → {tenantA}:tenantA:gpt-4:request
// tenantB's check lands on a different slot — hot-spot distributed
//   { type: 'token', key: 'tenantB:gpt-4' }    → {tenantB}:tenantB:gpt-4:token

After partitioning by tenant tag, the atomic unit shrinks from "entire store" to "rule set with the same tag": cross-tenant global rules (e.g., global gpt4 concurrency + tenant rules) can no longer be mixed in the same check — either drop the global rule, or split into two checks and accept non-atomicity.

Multi-Tenant Full Example

Group by tenantId into modelRegistry, withLimit key uses tenant id, quotas read from tenantLimits; store uses RedisStore with per-tenant hash tag, distributing each tenant's rate-limit keys across different Redis Cluster slots.

typescript
import { augmentModel, runWith, type ModelAdapter } from '@rejelly/core';
import { withLimit, RedisStore } from '@rejelly/limit-model';

// ⚠️ The split rule must match your key naming convention: this example's keys start with the tenant id
//    (`tenantId`, `${tenantId}:cheap`), so taking the first segment yields the tenant.
//    If copied from the withLimit example above with 'tenant:A:gpt4' naming, all tenants would compute
//    the same tag "tenant" and partitioning would fail.
//    After partitioning by tag, a single withLimit's rules must not mix cross-tenant global rules
//    (e.g., global 'gpt4') — different tags will throw directly.
const tenantStore = new RedisStore(redisClient, {
  hashTag: (rule) => rule.key.split(':')[0],
});

function getModelRegistry(tenantId: string): Record<string, ModelAdapter> {
  const expensiveBase = createOpenAIAdapter({ modelId: 'gpt-5.6-sol' });
  const cheapBase = createOpenAIAdapter({ modelId: 'gpt-5.6-luna' });
  const limits = tenantLimits[tenantId];  // e.g., VIP { concurrency: 100 } vs normal { concurrency: 5 }
  return {
    'expensive': augmentModel(expensiveBase, [
      withLimit({ store: tenantStore, rules: [{ type: 'concurrency', key: tenantId, limit: limits.concurrency }] }),
    ]),
    'cheap': augmentModel(cheapBase, [
      withLimit({ store: tenantStore, rules: [{ type: 'concurrency', key: `${tenantId}:cheap`, limit: limits.concurrency * 2 }] }),
    ]),
  };
}
await runWith(async () => await MyAgent(props), { modelRegistry: getModelRegistry(req.tenantId) });

Error Types

ErrorcodeWhen thrownKey fields
RateLimitExceededError429Store determines limit exceeded (concurrency / RPM / TPM)retryAfterMs (maps directly to HTTP Retry-After), reason (limit dimension), failedRule (the rule that caused the limit)
TokenLimitExceededError413Estimated tokens exceed a token rule's absolute limit — waiting won't helpestimatedTokens, limit; client must shorten input, not retry

Both are exported from @rejelly/limit-model. Note the distinction: 429 means "retry later", 413 means "will never succeed" — do not confuse them when mapping to HTTP status codes.

Released under the Apache License 2.0. llm.txt