Sliding Window Rate Limiter (TypeScript)

Enforce per-key request limits over a sliding time window with cost support, retry hints, remaining quota, and expired-entry cleanup.

TypescriptResilience

Code

snippet
export interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  retryAfterMs: number;
  resetAt: number;
}

export class SlidingWindowRateLimiter {
  private readonly hits = new Map<string, number[]>();

  public constructor(
    private readonly limit: number,
    private readonly windowMs: number,
    private readonly now: () => number = Date.now,
  ) {
    if (!Number.isInteger(limit) || limit < 1) {
      throw new RangeError("limit must be a positive integer");
    }
    if (!Number.isFinite(windowMs) || windowMs <= 0) {
      throw new RangeError("windowMs must be a positive finite number");
    }
    if (typeof now !== "function") {
      throw new TypeError("now must be a function");
    }
  }

  public consume(key: string, cost = 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 || cost > this.limit) {
      throw new RangeError("cost must be an integer between 1 and limit");
    }

    const timestamp = this.now();
    const cutoff = timestamp - this.windowMs;
    const timestamps = (this.hits.get(key) ?? []).filter(
      (value) => value > cutoff,
    );

    if (timestamps.length + cost > this.limit) {
      const oldest = timestamps[0] ?? timestamp;
      this.hits.set(key, timestamps);
      return {
        allowed: false,
        remaining: Math.max(0, this.limit - timestamps.length),
        retryAfterMs: Math.max(0, oldest + this.windowMs - timestamp),
        resetAt: oldest + this.windowMs,
      };
    }

    for (let index = 0; index < cost; index += 1) {
      timestamps.push(timestamp);
    }
    this.hits.set(key, timestamps);
    return {
      allowed: true,
      remaining: this.limit - timestamps.length,
      retryAfterMs: 0,
      resetAt: timestamps[0] + this.windowMs,
    };
  }

  public reset(key: string): boolean {
    return this.hits.delete(key);
  }

  public prune(): number {
    const cutoff = this.now() - this.windowMs;
    let removed = 0;
    for (const [key, timestamps] of this.hits) {
      const active = timestamps.filter((value) => value > cutoff);
      if (active.length === 0) {
        this.hits.delete(key);
        removed += 1;
      } else {
        this.hits.set(key, active);
      }
    }
    return removed;
  }
}

let now = 0;
const limiter = new SlidingWindowRateLimiter(3, 1000, () => now);
for (let index = 0; index < 3; index += 1) {
  console.log(limiter.consume("user:1"));
}
console.log(limiter.consume("user:1"));

now = 600;
console.log(limiter.consume("user:1", 2));
now = 1001;
console.log(limiter.consume("user:1"));
console.log(limiter.consume("user:2", 2));

now = 3000;
console.log(limiter.prune());