Fixed Window Rate Limiter With Burst Accounting
Limit requests per key with fixed windows, explicit costs, reset timestamps, retry hints and bounded state for many client keys.
Code
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfterMs: number;
}
export interface RateLimiterOptions {
limit: number;
windowMs: number;
now?: () => number;
maxKeys?: number;
}
interface WindowState {
count: number;
resetAt: number;
}
export class FixedWindowRateLimiter {
private readonly limit: number;
private readonly windowMs: number;
private readonly now: () => number;
private readonly maxKeys: number;
private readonly windows = new Map<string, WindowState>();
constructor(options: RateLimiterOptions) {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new RangeError("limit must be a positive integer");
}
if (!Number.isFinite(options.windowMs) || options.windowMs <= 0) {
throw new RangeError("windowMs must be a positive finite number");
}
const maxKeys = options.maxKeys ?? 10_000;
if (!Number.isInteger(maxKeys) || maxKeys < 1) {
throw new RangeError("maxKeys must be a positive integer");
}
this.limit = options.limit;
this.windowMs = options.windowMs;
this.now = options.now ?? Date.now;
this.maxKeys = maxKeys;
}
consume(key: string, cost: number = 1): RateLimitResult {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("key must be a non-empty string");
}
if (!Number.isInteger(cost) || cost < 1) {
throw new RangeError("cost must be a positive integer");
}
const now = this.now();
if (!Number.isFinite(now)) throw new RangeError("now must return a finite timestamp");
let state = this.windows.get(key);
if (!state || now >= state.resetAt) {
if (!state && this.windows.size >= this.maxKeys) this.evict(now);
state = { count: 0, resetAt: now + this.windowMs };
this.windows.set(key, state);
}
const allowed = state.count + cost <= this.limit;
if (allowed) state.count += cost;
return {
allowed,
remaining: Math.max(0, this.limit - state.count),
resetAt: state.resetAt,
retryAfterMs: allowed ? 0 : Math.max(0, state.resetAt - now),
};
}
reset(key?: string): void {
if (key === undefined) this.windows.clear();
else this.windows.delete(key);
}
get size(): number {
return this.windows.size;
}
private evict(now: number): void {
for (const [key, state] of this.windows) {
if (now >= state.resetAt) this.windows.delete(key);
}
if (this.windows.size < this.maxKeys) return;
const oldest = this.windows.keys().next();
if (!oldest.done) this.windows.delete(oldest.value);
}
}Report a problem with this exampleOpens a pre-filled issue on GitHub.