Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

Packages

Rate limit

Per-client request limits with sliding-window or token-bucket algorithms, standard RateLimit headers, and in-memory or Redis stores.

On this page 24 sections
Terminal
bun add @knead-js/core @knead-js/http @knead-js/rate-limit

@knead-js/core and @knead-js/http are peer dependencies, so every package shares one copy. RedisStore uses Bun's built-in Redis client, so no Redis package is needed.

@knead-js/rate-limit counts requests per client and answers 429 Too Many Requests once a client goes over its budget. A global middleware applies one limit to every request; decorators give individual routes their own limits or exempt them; a guard applies the same limits after authentication, so you can limit per user. Counters live in memory for a single process, or in Redis when several instances must share them. For how it fits with CORS and security headers, see security.

#RateLimitModule

RateLimitModule.forRoot(options: RateLimitModuleOptions): DynamicModule validates the options with validateRateLimitOptions() and returns a global module. It provides RATE_LIMIT_OPTIONS, RATE_LIMIT_STORE (your store, or a new MemoryStore), RateLimiter, RateLimitMiddleware and RateLimitGuard, exports all but the middleware, and registers RateLimitMiddleware as global middleware with priority 900 unless middleware is false. The store's close() runs when the application shuts down.

app.module.ts
@Module({
  imports: [RateLimitModule.forRoot({ limit: 100, window: '1m' })],
  controllers: [AccountController],
})
export class AppModule {}

#RateLimitModuleOptions

OptionTypeDefaultDescription
limitnumberrequiredRequests allowed per window. A positive integer.
windowDurationrequiredThe window, such as '1m'. See durations.
algorithmAlgorithm'sliding-window''sliding-window' or 'token-bucket', for every limit of the module. See algorithms.
storeRateLimitStorea new MemoryStore()Where counters live.
keyKeyFnthe client IPWhich client a request belongs to. See keys.
ipv6Subnetnumber64Prefix length the default key masks IPv6 addresses to. An integer from 1 to 128; 128 keys each address.
onMissingKey'skip', 'shared' or 'deny''shared'What to do when the key function returns no key. See keys.
onStoreError'allow' or 'deny''allow'What to do when the store throws. See store failures.
middlewarebooleantrueInstall the global RateLimitMiddleware. Set false to count only through RateLimitGuard.

validateRateLimitOptions(options: RateLimitModuleOptions): void throws a RangeError for a limit that is not a positive integer, an invalid or out-of-range window, an ipv6Subnet outside 1 to 128 (0 would put every IPv6 client in one bucket), an unknown onMissingKey, onStoreError or algorithm, a store without a consume() method, a key that is not a function and a middleware that is not a boolean. An unknown onStoreError is rejected rather than treated as 'allow', and a malformed store rather than failing on every request, so a mistake cannot silently make the limit fail open.

#Counters

Each request is counted against one counter, identified by a scope and the client key:

  • Routes without @RateLimit(), unmatched requests (404 and 405) and WebSocket upgrades share one global counter per client. (An upgrade on the path of a GET route counts like that route.) A client with a limit of 100 gets 100 requests per window across all of them.
  • Each route with @RateLimit() has its own counter per client, named after its method and path.
  • RateLimitGuard keeps counters separate from the middleware's, so a request passing through both counts once in each.

CORS preflights do not count when CorsModule is installed: it answers them at priority 1000, before rate limiting runs.

#Decorators

account.controller.ts
@Controller('/')
export class AccountController {
  // One counter for this route: 5 attempts per client IP every 15 minutes.
  @Post('/login')
  @RateLimit({ limit: 5, window: '15m' })
  login() {
    return { token: 'issued' };
  }

  // Keyed by API key instead of IP; requests without the header share one bucket.
  @Get('/reports')
  @RateLimit({
    limit: 1000,
    window: '1h',
    key: (ctx: Ctx) => ctx.req.headers.get('x-api-key') ?? undefined,
  })
  reports() {
    return [];
  }

  @Get('/health')
  @SkipRateLimit()
  health() {
    return { ok: true };
  }

  @Get('/profile')
  profile() {
    return { name: 'Ada' };
  }
}

#@RateLimit()

RateLimit(options: RateLimitRouteOptions) gives a route (method decorator) or every route of a controller (class decorator) its own counter. RateLimitRouteOptions:

OptionTypeDefaultDescription
limitnumberthe module's limitA positive integer.
windowDurationthe module's windowParsed with parseDuration().
keyKeyFnthe module's keyThe key function for this route.

The options are validated when the decorator is applied: a limit that is not a positive integer, an invalid window or a key that is not a function throws a RangeError at import time. The decorator stores RateLimitRouteMeta (the options plus windowMs, the parsed window in milliseconds) in route metadata under RATE_LIMIT_META.

A method decorator replaces a controller decorator rather than merging with it: options the method leaves out come from the module, not from the controller.

#@SkipRateLimit()

SkipRateLimit() exempts a route or controller from the middleware and the guard: the request is not counted and gets no RateLimit-* headers. It stores true under SKIP_RATE_LIMIT_META. Use it for health checks that a load balancer polls.

#Keys

KeyFn is (ctx: Ctx) => string | undefined | Promise<string | undefined>. The default key is ctx.ip, with IPv6 addresses masked to ipv6Subnet bits by maskIp(): one client usually controls a whole /64 and could otherwise rotate addresses to reset its budget. IPv4-mapped IPv6 addresses are keyed as IPv4.

When the key function returns undefined or an empty string (for example ctx.ip on a Unix socket, or a header that is missing), onMissingKey decides:

ValueBehavior
'shared'The request counts against one anonymous counter per scope, shared by every keyless request.
'skip'The request is not limited.
'deny'The request is rejected with 403 Forbidden (detail Rate limiting requires a client key).

The first keyless request logs a warning.

The middleware runs before guards, so its key function sees the raw request but nothing a guard set, such as ctx.state.user. To limit per user, either verify the credential inside the key function, or count in the guard phase with RateLimitGuard.

#RateLimitGuard

RateLimitGuard applies the same module options and @RateLimit() overrides as a guard. Guards run in order, so placed after your authentication guard its key function can read what that guard stored. It always returns true when the request is within its limit, and sets the RateLimit-* headers on the handler's response through ctx.res.headers.

usage.module.ts
/** Resolves the caller's account from its API key and stores it for later guards. */
export class ApiKeyGuard implements Guard {
  canActivate(ctx: Ctx): boolean {
    const account = apiKeys.get(ctx.req.headers.get('x-api-key') ?? '');
    if (account === undefined) return false;
    ctx.state.accountId = account;
    return true;
  }
}

@Controller('/usage')
@UseGuards(ApiKeyGuard, RateLimitGuard)
export class UsageController {
  @Get('/')
  usage() {
    return { calls: 42 };
  }
}

@Module({
  imports: [
    RateLimitModule.forRoot({
      limit: 1000,
      window: '1h',
      // Only the guard counts; the global middleware is not installed.
      middleware: false,
      // Runs after ApiKeyGuard, so the account is known.
      key: (ctx) => {
        const account = ctx.state.accountId;
        return typeof account === 'string' ? account : ctx.ip;
      },
    }),
  ],
  controllers: [UsageController],
})
export class AppModule {}

Use it with @UseGuards(AuthGuard, RateLimitGuard) or as an APP_GUARD. With middleware: false only the guard counts.

RateLimitGuard also works on GraphQL resolvers (@UseGuards(RateLimitGuard) on a resolver class or method): it reads the request from ctx.http, keeps one counter per resolver field that has @RateLimit(), puts the headers on the GraphQL HTTP response, and a denial is a RATE_LIMITED GraphQL error. See GraphQL.

Key functions always receive the HTTP Ctx, on a resolver too: the default key is ctx.http.ip, and a module or @RateLimit() key is called with ctx.http, never the ResolverCtx. So the same key function works on routes and resolvers. For a per-user limit on resolvers, have the authentication guard store the user on ctx.http.state, which is what the key function sees (ResolverCtx.state is the operation's scratch space, a different object).

A guarded field without @RateLimit() counts against the guard's global budget once per HTTP request: a nested field in a list resolves once per item, but all its checks, and a RateLimitGuard on the GraphQL route itself, share one store call. A field with @RateLimit() counts every time it resolves, so its limit bounds how often that field runs.

#Responses

Every counted request that is allowed gets:

HeaderValue
RateLimit-LimitThe limit that applied.
RateLimit-RemainingRequests left.
RateLimit-ResetSeconds, rounded up, until the quota is fully restored: the end of the current window (sliding window) or until the bucket is full (token bucket).

A request over the limit is rejected with a TooManyRequestsException, rendered as an RFC 9457 problem response with Content-Type: application/problem+json; charset=utf-8, the same three headers and Retry-After. On a denial, RateLimit-Remaining is 0, and both RateLimit-Reset and Retry-After give the seconds, rounded up, until the earliest moment a request would be admitted (Retry-After is at least 1):

HTTP
HTTP/1.1 429 Too Many Requests
content-type: application/problem+json; charset=utf-8
x-content-type-options: nosniff
ratelimit-limit: 5
ratelimit-remaining: 0
ratelimit-reset: 840
retry-after: 840

{"type":"about:blank","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded","instance":"/login"}

Rejected requests do not consume quota. Browsers on other origins can read these headers only if CORS exposes them: list RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and Retry-After in exposedHeaders (see CORS).

#Store failures

When the store throws (Redis is down, or slower than commandTimeout), the failure is logged as a warning with a correlation id, and onStoreError decides what happens to the request. An outage fails every request, so the logging is throttled: the first failure is logged, then at most one line every 10 seconds (Rate limit store still failing (N failures not logged)), and the first success afterwards logs Rate limit store recovered after N failures.

ValueBehavior
'allow'Fail open: the request goes through, without RateLimit-* headers.
'deny'Fail closed: 503 Service Unavailable. Outside development the response carries no detail, like every 5xx.

Use 'deny' when the limit protects authentication (login, password reset, one-time codes): failing open would let an attacker brute-force while Redis is unavailable.

#Algorithms

Algorithm is 'sliding-window' | 'token-bucket'.

AlgorithmBehavior
'sliding-window'Counts requests in fixed windows of window length and weighs the previous window by how much of it still overlaps the sliding window ending now. A request is allowed while previous × overlap + current + 1 ≤ limit. It smooths the burst at window boundaries that a plain fixed window allows.
'token-bucket'A bucket of limit tokens, refilled continuously at limit tokens per window, starting full. Each request takes one token. Allows bursts up to limit, then a steady rate.

Both stores implement both algorithms with the same arithmetic. A store is told the rule on each call (RateLimitRule: { limit, windowMs, algorithm }), so routes with different limits share a store.

#Durations

Duration is number | `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`: a number of milliseconds, or a string such as '500ms', '15s', '1.5m', '1h' or '1d'.

parseDuration(value: Duration | string): number returns whole milliseconds, rounded up and at least 1. It throws a RangeError for anything else ('1w', '1 m', null, undefined, an object), for zero, negative or non-finite values, and for more than MAX_DURATION_MS, 31536000000 (365 days).

#Stores

#MemoryStore

The default store. Counters live in the process, so every process (or Bun worker) has its own: with four instances behind a load balancer, a client effectively gets four times the limit. Use RedisStore for more than one process.

new MemoryStore(options?: MemoryStoreOptions). MemoryStoreOptions:

OptionTypeDefaultDescription
maxKeysnumber10000Most counters kept (an integer of at least 1). The least recently used are evicted, which resets them. A flood of distinct keys can therefore reset other clients' counters: size it for your key cardinality, or use RedisStore.
sweepIntervalMsnumber60000How often expired counters are removed. 0 disables the timer. A negative, NaN or infinite value is a RangeError. The timer does not keep the process alive.
hashKeysbooleantrueStore keys as HMAC-SHA256 hashes (32 hex characters) under a random per-process secret, so client IPs and API keys cannot be read from a memory dump and long keys cost a fixed size.
now() => numberDate.nowThe clock, for tests.

It also has size (the number of counters), sweep() and close() (stops the timer).

#RedisStore

Counters shared by every instance through Redis or Valkey. Each consume() is one Lua script (EVALSHA, falling back to EVAL when the script is not cached), so checks are atomic, and the script uses the Redis server's TIME as its clock, so whether a request is admitted does not depend on instance clocks. The RateLimit-Reset and Retry-After headers are computed from the store's resetAt against the instance clock, so a skewed instance reports them off by its skew.

rate-limit.module.ts
export function rateLimitModule(env: Record<string, string | undefined>) {
  return RateLimitModule.forRoot({
    limit: 100,
    window: '1m',
    algorithm: 'token-bucket',
    store: new RedisStore({
      url: env.RATE_LIMIT_REDIS_URL, // rediss://ratelimit:password@redis.internal:6380
      hashSecret: env.RATE_LIMIT_HASH_SECRET, // openssl rand -base64 32, same on every instance
      requireTls: true,
    }),
    // A Redis outage rejects requests with 503 instead of letting them through.
    onStoreError: 'deny',
  });
}

new RedisStore(options?: RedisStoreOptions). RedisStoreOptions:

OptionTypeDefaultDescription
urlstringBun's default (REDIS_URL, VALKEY_URL, then localhost)Connection URL, used when no client is given.
clientRedisClientnoneAn existing Bun RedisClient. It is not closed by close(), and it skips the plaintext check: configure TLS on it yourself. Give it enableOfflineQueue: false and a connectionTimeout so commands fail fast while Redis is unreachable.
prefixstring'knead:rl:'Prefix of every key.
hashKeysbooleantrueStore hashes (32 hex characters) instead of raw keys.
hashSecretstring or Uint8ArraynoneKey for the hashes (HMAC-SHA256), at least 32 bytes and the same on every instance. Without it, the hash is plain SHA-256, and (with hashKeys on) a warning is logged once per process.
requireTlsbooleanfalseThrow an HttpConfigError at construction instead of only warning when the URL is plaintext to a remote host.
commandTimeoutnumber250Milliseconds to wait for Redis before the call fails as a store error. An integer of at least 1.
loggerLoggerthe application's LOGGERWhere the warnings go. Without it, the construction warnings are held until RateLimitModule starts and passes its LOGGER (setLogger()); a store used without the module sends them to a ConsoleLogger('knead:rate-limit') at its first consume().

When it builds its own client (no client option), the store creates it with enableOfflineQueue: false and a connectionTimeout of max(commandTimeout, 1000) ms, and connects before sending. While Redis is unreachable, each consume() fails within commandTimeout as a store error instead of queueing a script that would be replayed, and consume counters, once Redis is back.

Rules that protect the data in Redis:

  • Plaintext connections. A redis:// or valkey:// URL (from url, REDIS_URL or VALKEY_URL) to a host that is not loopback sends keys and credentials unencrypted. The constructor logs a warning, or throws an HttpConfigError with requireTls: true. rediss:// and valkeys:// URLs, Unix sockets and loopback hosts pass.
  • Hashed keys. Keys are hashed by default. A plain SHA-256 of an IP address does not hide it, since every IPv4 address can be hashed in seconds, so set hashSecret (for example from openssl rand -base64 32). A shorter secret is a RangeError.
  • Least privilege. Connect as an ACL user limited to the store's keys and the commands it runs, for example ACL SETUSER ratelimit on >password resetkeys ~knead:rl:* -@all +evalsha +eval +get +incr +pexpire +hmget +hset +time.

Keys include the window length, so changing a rule's window starts from fresh counters. The sliding-window script derives its keys inside Lua, so RedisStore does not support Redis Cluster.

#Custom stores

A store implements RateLimitStore:

MemberSignatureDescription
consume(key: string, rule: RateLimitRule) => Promise<ConsumeResult>Counts one request atomically and reports the outcome.
close() => void or () => Promise<void>, optionalCalled when the application shuts down.
setLogger(logger: Logger) => void, optionalCalled once when RateLimiter is created, with the application's LOGGER, for a store built outside dependency injection.

ConsumeResult is { allowed: boolean; remaining: number; resetAt: number }. resetAt is in epoch milliseconds: on a denial, the earliest time a request would be admitted (it becomes Retry-After); when allowed, the time the quota is fully restored (it becomes RateLimit-Reset).

#Functions

#maskIp()

maskIp(ip: string, prefix: number): string, re-exported from @knead-js/http, returns the key for a client address: IPv4 (including IPv4-mapped IPv6) as is, IPv6 masked to prefix bits and written as network/prefix (2001:db8:1:2::/64). Unparseable input is returned unchanged. It throws a RangeError unless prefix is an integer from 0 to 128.

#hashKey()

hashKey(key: string, secret?: string | Uint8Array): string returns the first 32 hex characters (128 bits) of HMAC-SHA256 under secret, or of a plain SHA-256 without one (both computed with Bun.CryptoHasher). Use it in a custom store.

#Classes and tokens

#RateLimiter

The check shared by the middleware and the guard. check(ctx: Ctx, phase: 'middleware' | 'guard') returns the RateLimit-* headers to send, undefined when the request is not limited (skipped, or allowed after a store failure), or throws the 429, 403 or 503 described above. It closes the store on module destroy.

#RateLimitMiddleware

The global middleware (priority 900). It calls RateLimiter.check() and adds the headers to the response.

#RATE_LIMIT_OPTIONS and RATE_LIMIT_STORE

InjectionToken<RateLimitModuleOptions> and InjectionToken<RateLimitStore>. Inject RATE_LIMIT_STORE to inspect or reuse the configured store.

#RATE_LIMIT_META and SKIP_RATE_LIMIT_META

The route metadata keys (Symbol('knead:rate-limit') and Symbol('knead:skip-rate-limit')) written by @RateLimit() and @SkipRateLimit().