@studnicky/keyed-rate-limiter
Per-key rate limiting composing
@studnicky/cacheand@studnicky/resilience.
Install
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:
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
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:
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:
KeyedRateLimiterCreateConfigInterfacesuppliesrequestsPerSecond,burstSize, and optionalclock,maxKeys, andkeyIdleTtlMsfor the defaultTokenBucket-per-key path.KeyedRateLimiterStrategyConfigInterface<TStrategy>suppliesfactory,maxKeys, andkeyIdleTtlMsfor any structural strategy implementation.
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 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
| Error | Thrown when |
|---|---|
KeyedRateLimiterConfigError | KeyedRateLimiter.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
Entities
@studnicky/keyed-rate-limiter/entities exports every schema namespace in src/entities.
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.
import type { KeyedRateLimiterCreateConfigInterface } from '@studnicky/keyed-rate-limiter/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
KeyedRateLimiter | Provides keyed rate limiter functionality. | @studnicky/keyed-rate-limiter |
KeyedRateLimiterConfigError | Represents keyed rate limiter config failures. | @studnicky/keyed-rate-limiter |
KeyedRateLimiterError | Represents keyed rate limiter failures. | @studnicky/keyed-rate-limiter |
RateLimiterStrategyInterface | Defines the rate limiter strategy contract. | @studnicky/keyed-rate-limiter |