← Runnable Projects

阅读中文版本

Sliding Window Rate Limiter

An in-memory sliding window rate limiter with an injectable clock, tested deterministically across window edges and bursts with exact verdicts.

beginnerjavascriptunverified

Project

node-rate-limiter-memory

Entry point: limiter.mjs

Install

node --version

Run

node limiter.mjs

Test

node --test limiter.test.mjs

Expected output

The demo drives ten requests at t=0 against a limit of three, printing true for the first three and false for the rest.

Dependencies

  • node >=20

What it does

A sliding window rate limiter with an injectable clock, so window expiry and burst behavior are tested deterministically without sleeping.

export class SlidingWindowLimiter {
  constructor({ limit, windowMs, now = () => Date.now() } = {}) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.now = now;
    this.hits = new Map();
  }

  check(key = 'default') {
    const t = this.now();
    const bucket = (this.hits.get(key) ?? []).filter((ts) => t - ts < this.windowMs);
    const allowed = bucket.length < this.limit;
    if (allowed) bucket.push(t);
    this.hits.set(key, bucket);
    return allowed;
  }

  remaining(key = 'default') {
    const t = this.now();
    const bucket = (this.hits.get(key) ?? []).filter((ts) => t - ts < this.windowMs);
    return Math.max(0, this.limit - bucket.length);
  }
}

let clock = 0;
const limiter = new SlidingWindowLimiter({ limit: 3, windowMs: 1000, now: () => clock });
const verdicts = [];
for (let i = 0; i < 10; i++) verdicts.push(limiter.check());
console.log(verdicts.join(','));
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SlidingWindowLimiter } from './limiter.mjs';

function makeLimiter(limit, windowMs) {
  let clock = 0;
  return { clock: () => (clock += 0), limiter: new SlidingWindowLimiter({ limit, windowMs, now: () => clock }), set: (v) => { clock = v; } };
}

test('admits up to limit then rejects within the window', () => {
  const { limiter, set } = makeLimiter(3, 1000);
  set(0);
  assert.equal(limiter.check(), true);
  assert.equal(limiter.check(), true);
  assert.equal(limiter.check(), true);
  assert.equal(limiter.check(), false);
});

test('entries expire as the window slides', () => {
  const { limiter, set } = makeLimiter(2, 1000);
  set(100);
  limiter.check();
  set(200);
  limiter.check();
  assert.equal(limiter.check(), false);
  set(1100);
  assert.equal(limiter.check(), true);
});

test('keys are isolated from each other', () => {
  const { limiter, set } = makeLimiter(1, 1000);
  set(0);
  assert.equal(limiter.check('a'), true);
  assert.equal(limiter.check('a'), false);
  assert.equal(limiter.check('b'), true);
});

test('remaining reflects capacity after partial use', () => {
  const { limiter, set } = makeLimiter(5, 1000);
  set(0);
  limiter.check();
  limiter.check();
  assert.equal(limiter.remaining(), 3);
});

Run and verify

node --test limiter.test.mjs
node limiter.mjs

Run the tests for expiry and key isolation, then the demo that drains a three-request budget at t=0.

Expected Output

The test run should print four passing tests for admission, window sliding, key isolation, and remaining capacity — all deterministic because the clock is injected rather than slept on. The demo’s console.log output should print ten verdicts joined by commas; with the limit set to three at a frozen t=0, the Output: is:

true,true,true,false,false,false,false,false,false,false

The first three requests drain the budget, the remaining seven are rejected inside the same window, and advancing the injected clock past windowMs would start admitting again as old entries slide out of the bucket.

Limitations

State is per process, so multiple replicas each enforce the full limit; cross-process strictness needs a shared store.