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

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 { RateLimiterStrategyType } from '../src/index.js';

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

class FixedClock {
  static now(): number {
    const timestamp = 0;
    return timestamp;
  }
}

class TelemetryKeyedRateLimiter extends KeyedRateLimiter {
  readonly events: string[] = [];

  static tracked(): TelemetryKeyedRateLimiter {
    // Calling `create()` through the subclass reference (not
    // `KeyedRateLimiter.create()`) matters: `create()`/`createWithStrategy()`
    // use the polymorphic `new this(...)` idiom, so only a call routed
    // through `TelemetryKeyedRateLimiter` actually constructs one.
    const result = TelemetryKeyedRateLimiter.create({
      'burstSize': 2,
      'clock': FixedClock.now,
      'maxKeys': 2,
      'requestsPerSecond': 1
    }) as TelemetryKeyedRateLimiter;
    return result;
  }

  protected override onKeyCreated(key: string): void {
    console.log(`[keyed-rate-limiter] key created key=${key}`);
    this.events.push(`created:${key}`);
  }

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

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

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

const limiter = TelemetryKeyedRateLimiter.tracked();

// 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

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

console.log('Events:', limiter.events);

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

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

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

The RateLimiterStrategyType extension seam

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

ts
// json-schema-uninexpressible: a purely structural method-signature seam (consume/waitForToken are
// function types) — this is a behavioral contract, not a JSON-serializable data shape.
export type RateLimiterStrategyType = {
  /** Throws when insufficient capacity is available for `tokens`. */
  consume(tokens?: number): void;
  /** Resolves once `tokens` capacity is available, or rejects on abort. */
  waitForToken(options?: { 'signal'?: AbortSignal; 'tokens'?: number }): Promise<void>;
};

@studnicky/resilience's TokenBucket matches this shape without declaring or importing it. KeyedRateLimiter.create() composes a TokenBucket per key by default; KeyedRateLimiter.createWithStrategy() accepts any factory producing an object with those two methods — a future rate-limiting algorithm slots in without a second wrapper class, and without ever importing from @studnicky/keyed-rate-limiter or coupling to TokenBucket.

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 — capacity eviction, idle TTL expiry, or a direct getCache().delete(key) all route here
onLimitExceeded(key)key's strategy consume() throws, before the error propagates
onTokenAcquired(key, count)A successful acquisition on the default create() path only — delegated from the per-key TokenBucket's own hook; createWithStrategy() has no hook surface to delegate through for an arbitrary caller-supplied strategy

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

Transparency contract

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

GetterReturns
getCache()The composed LruCache<string, TStrategy> instance

Every getter returns the exact instance used internally — never a copy or wrapper. onKeyEvicted is delegated from the internally-composed LruCache's own onEvict/onExpire/onDelete hooks (capacity eviction, idle TTL expiry, and direct deletion respectively), the same delegation technique @studnicky/paginator's Paginator and @studnicky/idempotency-guard's IdempotencyGuard use for their own internally-composed instances. onTokenAcquired is delegated from the per-key TokenBucket's own hook, but only on the create() path — createWithStrategy() has no structural hook surface to delegate through for an arbitrary caller-supplied strategy.

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
KeyedRateLimiterConfigErrorKeyedRateLimiterBuilder#build() is called without requestsPerSecond or burstSize

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