Skip to content

@studnicky/keyed-rate-limiter

Per-key rate limiting composing @studnicky/cache and @studnicky/resilience.

Install

bash
pnpm add @studnicky/keyed-rate-limiter

@studnicky/keyed-rate-limiter declares a root usage API and explicit public subpaths.

Usage

KeyedRateLimiter#consume(key, tokens?) / #waitForToken(key, options?) lazily create one rate-limiting strategy per key on first use, backed by a composed LruCache that bounds and evicts idle keys. Draining one key's strategy has no effect on any other key:

ts
import { TokenBucketExhaustedError } from '@studnicky/resilience';
import assert from 'node:assert/strict';

import type { RateLimiterStrategyInterface } from '../src/index.js';

import { KeyedRateLimiter } from '../src/index.js';

const telemetryEvents: string[] = [];

class TelemetryKeyedRateLimiter extends KeyedRateLimiter {
  protected override onKeyCreated(key: string): void {
    console.log(`[keyed-rate-limiter] key created key=${key}`);
    telemetryEvents.push(`created:${key}`);
  }

  protected override onKeyEvicted(key: string): void {
    console.log(`[keyed-rate-limiter] key evicted key=${key}`);
    telemetryEvents.push(`evicted:${key}`);
  }

  protected override onLimitExceeded(key: string): void {
    console.log(`[keyed-rate-limiter] limit exceeded key=${key}`);
    telemetryEvents.push(`exceeded:${key}`);
  }

  protected override onTokenAcquired(key: string, count: number): void {
    console.log(`[keyed-rate-limiter] token acquired key=${key} count=${count}`);
    telemetryEvents.push(`acquired:${key}:${count}`);
  }
}

const limiter = TelemetryKeyedRateLimiter.create({
  'burstSize': 2,
  'clock': () => {
    const epoch = new Date(0);
    const result = epoch.getTime();
    return result;
  },
  'maximumKeys': 2,
  'requestsPerSecond': 1
});

// Two independent keys — draining user-a does not touch user-b
limiter.consume('user-a');
limiter.consume('user-a');

try {
  limiter.consume('user-a'); // exhausted
} catch (error) {
  if (!(error instanceof TokenBucketExhaustedError)) { throw error; }
}

limiter.consume('user-b'); // unaffected by user-a's exhaustion

// maximumKeys: 2 — a third key evicts the LRU tail (user-a)
limiter.consume('user-c');

console.log('Events:', telemetryEvents);

// The generic extension point: any object matching RateLimiterStrategyInterface
// slots in without a second wrapper class — no import of, or coupling to,
// TokenBucket required.
class FixedAllowance implements RateLimiterStrategyInterface {
  #remaining: number;
  constructor(allowance: number) { this.#remaining = allowance; }
  consume(tokens = 1): void {
    if (this.#remaining < tokens) { throw RuntimeError.create('exhausted'); }
    this.#remaining -= tokens;
  }
  waitForToken(options?: { 'signal'?: AbortSignal; 'tokens'?: number }): Promise<void> {
    this.consume(options?.tokens ?? 1);
    const result = Promise.resolve();
    return result;
  }
}

const genericLimiter = KeyedRateLimiter.create<FixedAllowance>({
  'factory': (_key) => {return new FixedAllowance(3);}
});

genericLimiter.consume('tenant-1', 3);

Try it

Loading example…

The output shows onKeyCreated/onTokenAcquired firing independently for user-a and user-b, onLimitExceeded firing once user-a's bucket is drained, and onKeyEvicted firing for user-a when a third key (user-c) exceeds maximumKeys and evicts the LRU tail.

The RateLimiterStrategyInterface extension seam

KeyedRateLimiter<TStrategy extends RateLimiterStrategyInterface = TokenBucket> is generic over an injectable rate-limiting strategy — a purely structural seam:

ts
import type { RateLimitRequestEntity } from '../entities/RateLimitRequestEntity.js';

/** Structural contract implemented by a per-key rate-limiting strategy. */
export interface RateLimiterStrategyInterface {
  /** Throws when insufficient capacity is available for `tokens`. */
  consume(tokens?: RateLimitRequestEntity.Type['tokens']): void;
  /** Resolves once `tokens` capacity is available, or rejects on abort. */
  waitForToken(options?: {
    'signal'?: AbortSignal;
    'tokens'?: RateLimitRequestEntity.Type['tokens'];
  }): Promise<void>;
}

@studnicky/resilience's TokenBucket matches this shape without declaring or importing it. KeyedRateLimiter.create(config) accepts either of two root-exported config families:

  • KeyedRateLimiterCreateConfigInterface supplies requestsPerSecond, burstSize, and optional clock, maxKeys, and keyIdleTtlMs for the default TokenBucket-per-key path.
  • KeyedRateLimiterStrategyConfigInterface<TStrategy> supplies factory, maxKeys, and keyIdleTtlMs for any structural strategy implementation.

Hooks

HookFires when
onKeyCreated(key)A key is seen for the first time (or re-seen after eviction) and its strategy is lazily created
onKeyEvicted(key)The internal LruCache removes a key's strategy through capacity eviction or idle TTL expiry
onLimitExceeded(key)key's strategy consume() throws, before the error propagates
onTokenAcquired(key, count)A successful acquisition on the default token-bucket config path; factory-supplied strategies own their acquisition telemetry

KeyedRateLimiter's own hooks are specifically about per-key rate-limiting semantics — never a restatement of generic cache/bucket lifecycle.

Encapsulation contract

KeyedRateLimiter's own hooks (onKeyCreated, onKeyEvicted, onLimitExceeded, onTokenAcquired) are specifically about per-key rate-limiting semantics:

The composed cache remains private. Callers observe rate-limiter behavior through consume(), waitForToken(), and the lifecycle hooks instead of mutating the limiter's owned storage. onKeyEvicted is delegated from the internally composed LruCache; onTokenAcquired is delegated from a per-key TokenBucket for the default config family. A factory-supplied strategy owns its own acquisition telemetry because RateLimiterStrategyInterface has no hook surface.

Composition order

consume()/waitForToken() resolve the key's strategy (cache hit → return it; cache miss → factory(key)cache.set()onKeyCreated), then delegate to the strategy's own method. consume() wraps the call in a try/catch that fires onLimitExceeded and rethrows on failure — it never suppresses the underlying error.

Errors

ErrorThrown when
KeyedRateLimiterConfigErrorKeyedRateLimiter.create(config) receives an invalid default or strategy configuration

consume()/waitForToken() throw whatever the underlying strategy throws on exhaustion — TokenBucketExhaustedError (from @studnicky/resilience) on the default create() path.

Documentation

Full reference: https://studnicky.github.io/substrate/packages/keyed-rate-limiter

Source on GitHub

Entities

@studnicky/keyed-rate-limiter/entities exports every schema namespace in src/entities.

typescript
import { RateLimitRequestEntity } from '@studnicky/keyed-rate-limiter/entities';

Interfaces

@studnicky/keyed-rate-limiter/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { KeyedRateLimiterCreateConfigInterface } from '@studnicky/keyed-rate-limiter/interfaces';

Exports

SymbolPurposeImport path
KeyedRateLimiterProvides keyed rate limiter functionality.@studnicky/keyed-rate-limiter
KeyedRateLimiterConfigErrorRepresents keyed rate limiter config failures.@studnicky/keyed-rate-limiter
KeyedRateLimiterErrorRepresents keyed rate limiter failures.@studnicky/keyed-rate-limiter
RateLimiterStrategyInterfaceDefines the rate limiter strategy contract.@studnicky/keyed-rate-limiter