@studnicky/sliding-window-limiter
Sliding-window rate limiter — exact timestamp-log or approximate blended-counter algorithm. Independently usable and composable.
Install
pnpm add @studnicky/sliding-window-limiterRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Usage
Rate-limits by counting requests within a rolling time window rather than a continuously-refilling bucket — the single most common rate-limit ask. Two algorithms, selected via algorithm:
'log'— exact. A bounded queue of admitted request timestamps (capacity =limit) is pruned of stale entries on everyconsume(). No false positives or negatives,O(limit)memory.'counter'— approximate,O(1)space. Blends the current and previous fixed window's counts by the elapsed fraction of the current window, smoothing the boundary discontinuity a naive fixed-window counter would show.
import { SlidingWindowExhaustedError } from '../src/SlidingWindowExhaustedError.js';
import { SlidingWindowLimiter } from '../src/SlidingWindowLimiter.js';
class TracedLimiter extends SlidingWindowLimiter {
readonly allowed: number[] = [];
readonly rejected: number[] = [];
readonly rolls: number[] = [];
constructor(options: SlidingWindowLimiterOptionsInterface) { super(options); }
protected override onAllow(count: number): void {
console.log(`[sliding-window-limiter] onAllow — effective count=${count}`);
this.allowed.push(count);
}
protected override onReject(count: number): void {
console.log(`[sliding-window-limiter] onReject — effective count=${count}`);
this.rejected.push(count);
}
protected override onWindowRoll(): void {
console.log('[sliding-window-limiter] onWindowRoll — window boundary advanced');
this.rolls.push(1);
}
}
// A deterministic (virtual) clock — no real timers, fully reproducible.
let time = 0;
class Clock {
static now(): number { const result = time; return result; }
}
// --- Scenario 1: 'log' algorithm — exact timestamp pruning ---
console.log('\n--- log algorithm ---');
const logLimiter = new TracedLimiter({ 'algorithm': 'log', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
logLimiter.consume(); // t=0, admitted (1/2)
logLimiter.consume(); // t=0, admitted (2/2)
try {
logLimiter.consume(); // t=0, rejected — at limit
} catch (err) {
if (err instanceof SlidingWindowExhaustedError) {
console.log(' caught SlidingWindowExhaustedError as expected');
}
}
time = 101; // fully past the window
logLimiter.consume(); // succeeds — stale entries pruned first
// --- Scenario 2: 'counter' algorithm — blended fixed-window estimate ---
console.log('\n--- counter algorithm ---');
time = 0;
const counterLimiter = new TracedLimiter({ 'algorithm': 'counter', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
counterLimiter.consume(); // window 0, admitted (1/2)
counterLimiter.consume(); // window 0, admitted (2/2)
try {
counterLimiter.consume(); // window 0, rejected — at limit
} catch (err) {
if (err instanceof SlidingWindowExhaustedError) {
console.log(' caught SlidingWindowExhaustedError as expected');
}
}
time = 199; // near the far edge of the next window — blend has decayed
counterLimiter.consume(); // succeeds — decayed blended estimate is under the limit
// --- Scenario 3: waitForToken polls until admission succeeds ---
console.log('\n--- waitForToken ---');
const waiter = new TracedLimiter({ 'algorithm': 'counter', 'limit': 1, 'windowMs': 30 });
waiter.consume(); // exhaust the single slot
await waiter.waitForToken(); // resolves once the blended estimate decays below the limit
console.log(' waitForToken resolved');Structural fit with @studnicky/keyed-rate-limiter
consume(tokens?: number): void and waitForToken(options?: { signal?: AbortSignal; tokens?: number }): Promise<void> structurally match the rate-limiter strategy seam @studnicky/keyed-rate-limiter expects from a single-instance limiter it wraps per-key — the same method names and parameter shapes TokenBucket already exposes. Neither package imports the other; the fit is TypeScript structural typing only.
tokens is accepted on both methods purely for that structural match. Sliding-window rate limiting is one-request-at-a-time by nature, so the value passed is otherwise ignored — every consume() call is treated as exactly one admitted request regardless of tokens.
Try it
The demo subclasses SlidingWindowLimiter and overrides onAllow, onReject, and onWindowRoll. It walks both algorithms through admission up to the limit, a rejection at the limit, and admission again once the window (or its blended estimate) clears — then demonstrates waitForToken polling until capacity frees up.
/** observedSlidingWindowLimiter — trace lifecycle hooks across both algorithms. Run: npx tsx examples/observedSlidingWindowLimiter.ts */
import assert from 'node:assert/strict';
import type { SlidingWindowLimiterOptionsInterface } from '../src/interfaces/SlidingWindowLimiterOptionsInterface.js';
// #region usage
import { SlidingWindowExhaustedError } from '../src/SlidingWindowExhaustedError.js';
import { SlidingWindowLimiter } from '../src/SlidingWindowLimiter.js';
class TracedLimiter extends SlidingWindowLimiter {
readonly allowed: number[] = [];
readonly rejected: number[] = [];
readonly rolls: number[] = [];
constructor(options: SlidingWindowLimiterOptionsInterface) { super(options); }
protected override onAllow(count: number): void {
console.log(`[sliding-window-limiter] onAllow — effective count=${count}`);
this.allowed.push(count);
}
protected override onReject(count: number): void {
console.log(`[sliding-window-limiter] onReject — effective count=${count}`);
this.rejected.push(count);
}
protected override onWindowRoll(): void {
console.log('[sliding-window-limiter] onWindowRoll — window boundary advanced');
this.rolls.push(1);
}
}
// A deterministic (virtual) clock — no real timers, fully reproducible.
let time = 0;
class Clock {
static now(): number { const result = time; return result; }
}
// --- Scenario 1: 'log' algorithm — exact timestamp pruning ---
console.log('\n--- log algorithm ---');
const logLimiter = new TracedLimiter({ 'algorithm': 'log', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
logLimiter.consume(); // t=0, admitted (1/2)
logLimiter.consume(); // t=0, admitted (2/2)
try {
logLimiter.consume(); // t=0, rejected — at limit
} catch (err) {
if (err instanceof SlidingWindowExhaustedError) {
console.log(' caught SlidingWindowExhaustedError as expected');
}
}
time = 101; // fully past the window
logLimiter.consume(); // succeeds — stale entries pruned first
// --- Scenario 2: 'counter' algorithm — blended fixed-window estimate ---
console.log('\n--- counter algorithm ---');
time = 0;
const counterLimiter = new TracedLimiter({ 'algorithm': 'counter', 'clock': Clock.now, 'limit': 2, 'windowMs': 100 });
counterLimiter.consume(); // window 0, admitted (1/2)
counterLimiter.consume(); // window 0, admitted (2/2)
try {
counterLimiter.consume(); // window 0, rejected — at limit
} catch (err) {
if (err instanceof SlidingWindowExhaustedError) {
console.log(' caught SlidingWindowExhaustedError as expected');
}
}
time = 199; // near the far edge of the next window — blend has decayed
counterLimiter.consume(); // succeeds — decayed blended estimate is under the limit
// --- Scenario 3: waitForToken polls until admission succeeds ---
console.log('\n--- waitForToken ---');
const waiter = new TracedLimiter({ 'algorithm': 'counter', 'limit': 1, 'windowMs': 30 });
waiter.consume(); // exhaust the single slot
await waiter.waitForToken(); // resolves once the blended estimate decays below the limit
console.log(' waitForToken resolved');
// #endregion usage
assert.deepStrictEqual(logLimiter.allowed, [1, 2, 1], 'log: admitted counts 1, 2, then 1 after pruning');
assert.deepStrictEqual(logLimiter.rejected, [2], 'log: rejected once at the limit');
assert.ok(logLimiter.rolls.length >= 1, 'log: window rolled at least once when pruning stale entries');
assert.strictEqual(counterLimiter.allowed.length, 3, 'counter: three admissions total');
assert.deepStrictEqual(counterLimiter.rejected, [2], 'counter: rejected once at the limit');
assert.ok(counterLimiter.rolls.length >= 1, 'counter: window rolled at least once');
assert.ok(waiter.allowed.length >= 2, 'waiter: initial consume + eventual waitForToken admission both recorded');
console.log('\nobservedSlidingWindowLimiter: all assertions passed');
Observability hooks
Subclass SlidingWindowLimiter and override protected hooks to add logging, metrics, or tracing without coupling the core to any observability library.
| Hook | When it fires | Args |
|---|---|---|
onAllow(count) | After a request is admitted | count: number — effective window count post-admission |
onReject(count) | Before throwing, when a request would exceed limit | count: number — effective window count pre-admission |
onWindowRoll() | When the window boundary advances — 'log': stale entries pruned; 'counter': fixed-window index changed | — |
The base class never calls any logger or metrics library. All hooks are no-ops by default.
API
| Export | Type | Description |
|---|---|---|
SlidingWindowLimiter | class | Sliding-window rate limiter ('log' or 'counter') |
SlidingWindowLimiterBuilder | class | Fluent builder for SlidingWindowLimiter |
SlidingWindowExhaustedError | class | Thrown by consume() when admission would exceed limit |
SlidingWindowLimiterConfigError | class | Thrown by create()/build() on invalid configuration |
SlidingWindowLimiterError | class | Package-level abstract error ancestor |
SlidingWindowLimiterOptionsInterface | type | { limit, windowMs, algorithm: 'log' | 'counter', clock? } |
SlidingWindowLimiter
| Member | Signature | Description |
|---|---|---|
consume | (tokens?: number) => void | Admits one request; throws SlidingWindowExhaustedError if it would exceed limit |
waitForToken | (options?: { signal?, tokens? }) => Promise<void> | Polls until consume() would succeed, then consumes |