@studnicky/keyed-rate-limiter
Per-key rate limiting composing
@studnicky/cacheand@studnicky/resilience.
Install
pnpm add @studnicky/keyed-rate-limiterUsage
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:
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:
// 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
| Hook | Fires 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:
| Getter | Returns |
|---|---|
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
| Error | Thrown when |
|---|---|
KeyedRateLimiterConfigError | KeyedRateLimiterBuilder#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