ae-rate-limiter
v1.0.0
Published
A flexible, pluggable rate-limiting library supporting token bucket, fixed window, and sliding window algorithms with Redis or in-memory backends
Maintainers
Readme
rate-limiter
A flexible rate-limiting library for Node.js. Framework-agnostic core with optional Express middleware.
npm install rate_limiterTable of Contents
- Architecture Overview
- Choosing Your Backend
- Choosing an Algorithm
- Lua vs Non-Lua Variants
- Usage Without Express
- Usage With Express
- CacheClient Interface
- API Reference
- Running the Dev Server
Architecture Overview
┌─────────────────────────────────────────────────┐
│ Your Code │
│ rateLimiter.execute("user:123") │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ Algorithm │
│ (TokenBucket / WindowFixedCount / ...) │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ CacheClient │
│ (get / set / increment) │
└──────┬───────────────────────────┬──────────────┘
│ │
▼ ▼
RedisClient MockCacheClient
(ioredis) (in-memory)Three layers:
- Algorithm — the rate-limiting logic (what counts, when to refill, what to reject)
- CacheClient — storage abstraction (how counts are stored and retrieved)
- Backend — the actual database or in-memory store
Choosing Your Backend
Every constructor takes a CacheClient. There are two built-in implementations:
RedisClient (production)
import Redis from 'ioredis';
import { RedisClient, TokenBucket } from 'rate_limiter';
const redis = new Redis({ host: 'localhost', port: 6379 });
const cache = new RedisClient(redis);
const limiter = new TokenBucket(cache, 100, 10);MockCacheClient (testing / development)
import { TokenBucket } from 'rate_limiter';
import MockCacheClient from './test/mock_cache_client.js';
const cache = new MockCacheClient();
const limiter = new TokenBucket(cache, 100, 10);Custom backend
Implement the CacheClient interface:
import type { CacheClient } from 'rate_limiter';
class MyCache implements CacheClient {
async get(key: string): Promise<string | null> { ... }
async set(key: string, value: string | number): Promise<void> { ... }
async increment(key: string): Promise<number> { ... }
}Choosing an Algorithm
Token Bucket
Tokens refill at a steady rate; burst up to capacity. Good for API rate limits where short bursts should be allowed.
| Variant | Storage | Race-safe? | Constructor |
|---|---|---|---|
| TokenBucket | Any CacheClient | No | new TokenBucket(cache, limit, fillRate) |
| TokenBucketLua | Redis only | Yes | new TokenBucketLua(redisClient, limit, fillRate) |
Parameters:
limit— bucket capacity (max tokens)fillRate— tokens replenished per second
How it works: Each request increments a counter. When (now - lastRefill) >= (limit / fillRate) * 1000ms, the counter resets to 0. If the counter exceeds limit, the request is rejected.
Fixed Window
A simple counter that resets every N milliseconds. Simple and memory-efficient.
| Variant | Storage | Race-safe? | Constructor |
|---|---|---|---|
| WindowFixedCount | Any CacheClient | No | new WindowFixedCount(cache, limit, timeWindowMs) |
| WindowFixedCountLua | Redis only | Yes | new WindowFixedCountLua(redisClient, limit, timeWindowMs) |
Parameters:
limit— max requests per windowtimeWindowMs— window duration in milliseconds
How it works: A counter and a refill timestamp are stored per key. When the window expires, both reset to 0. The counter increments until it hits the limit.
Sliding Window Log
Tracks individual request timestamps. Provides the most accurate windowing but uses more memory per key.
| Variant | Storage | Race-safe? | Constructor |
|---|---|---|---|
| SlidingWindowLog | Any CacheClient | No | new SlidingWindowLog(cache, limit, windowMs) |
| SlidingWindowLogLua | Redis only | Yes | new SlidingWindowLogLua(redisClient, limit, windowMs) |
Parameters:
limit— max requests per sliding windowwindowMs— window duration in milliseconds
How it works (non-Lua): Stores a JSON array of timestamps per key. On each request, entries older than now - windowMs are removed, the current timestamp is appended, and if the array length exceeds limit the request is rejected. The request is always logged even if rejected (strict logging).
How it works (Lua): Uses a Redis sorted set (ZSET). Timestamps are scores, unique request IDs are members. Old entries are removed via ZREMRANGEBYSCORE. The count is obtained via ZCARD.
Lua vs Non-Lua Variants
Every algorithm has two variants:
| Variant | Suffix | Requires Redis | Atomic | Use case |
|---|---|---|---|---|
| Non-Lua | (no suffix) | No | No | Testing, single-server, low-concurrency |
| Lua | *Lua | Yes | Yes | Production, multi-server, high-concurrency |
Why use Lua?
The non-Lua variants make separate calls for get, set, and increment. Between these calls, another process or request can interleave, causing a race condition:
Request A: get(key) → 99
Request B: get(key) → 99
Request A: 99 >= 100? No → set(key, 100)
Request B: 99 >= 100? No → set(key, 100) ← BOTH PASSED, limit exceeded!Lua scripts run atomically inside Redis — no other commands execute while the script runs. This guarantees correctness under concurrent load.
// Safe for production — atomic Redis Lua execution
import { TokenBucketLua } from 'rate_limiter';
const limiter = new TokenBucketLua(redisClient, 100, 10);The *Lua classes take RedisClient (not CacheClient) because they call eval() to run scripts. The non-Lua classes take CacheClient and work with any backend.
Usage Without Express
The core library has zero framework dependencies — just call execute(key).
Basic rate limiting
import { TokenBucket } from 'rate_limiter';
import MockCacheClient from './test/mock_cache_client.js';
const cache = new MockCacheClient();
const limiter = new TokenBucket(cache, 5, 1); // 5 requests/sec
async function handleRequest(userId: string) {
const allowed = await limiter.execute(userId);
if (!allowed) {
return { status: 429, body: 'Too many requests' };
}
return { status: 200, body: 'OK' };
}With Redis in production
import Redis from 'ioredis';
import { RedisClient, SlidingWindowLogLua } from 'rate_limiter';
const redis = new Redis();
const cache = new RedisClient(redis);
const limiter = new SlidingWindowLogLua(cache, 100, 60_000);
const allowed = await limiter.execute('api-key-abc123');In a Fastify route
import Fastify from 'fastify';
import { TokenBucketLua, RedisClient } from 'rate_limiter';
import Redis from 'ioredis';
const app = Fastify();
const limiter = new TokenBucketLua(
new RedisClient(new Redis()),
100, 10
);
app.get('/api', async (req, reply) => {
const key = req.ip ?? 'unknown';
const allowed = await limiter.execute(key);
if (!allowed) return reply.status(429).send('rate limited');
return { ok: true };
});In a plain HTTP server
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { TokenBucket, RedisClient } from 'rate_limiter';
import Redis from 'ioredis';
const limiter = new TokenBucket(new RedisClient(new Redis()), 100, 10);
createServer(async (req: IncomingMessage, res: ServerResponse) => {
const key = req.socket.remoteAddress ?? 'unknown';
const allowed = await limiter.execute(key);
if (!allowed) {
res.writeHead(429);
return res.end('Too many requests');
}
res.writeHead(200);
res.end('OK');
}).listen(3000);Usage With Express
Simple setup (TokenBucket, IP-based key)
import express from 'express';
import Redis from 'ioredis';
import { RedisClient, createRateLimiter } from 'rate_limiter';
const app = express();
const cache = new RedisClient(new Redis());
app.use('/api', createRateLimiter({
limit: 100,
fillRate: 10,
cacheClient: cache,
}));Custom algorithm (use Lua for production)
import { TokenBucketLua } from 'rate_limiter';
app.use('/api', createRateLimiter({
limit: 100,
fillRate: 10,
cacheClient: cache,
algorithm: TokenBucketLua, // ← atomic Lua variant
}));Custom key extractor (rate-limit by API key)
app.use('/api', createRateLimiter({
limit: 1000,
fillRate: 50,
cacheClient: cache,
keyExtractor: (req) => req.headers['x-api-key'] as string ?? 'unknown',
}));Full example with sliding window
import { SlidingWindowLogLua, createRateLimiter } from 'rate_limiter';
app.use('/api', createRateLimiter({
limit: 60,
fillRate: 60, // ignored by SlidingWindowLog
cacheClient: cache,
algorithm: SlidingWindowLogLua,
}));Note:
fillRateis unused bySlidingWindowLog*andWindowFixedCount*— they only needlimitand the time window is passed at construction. ButcreateRateLimiteralways passeslimitandfillRateto the algorithm constructor. For custom algorithms with different signatures, set up theControllerdirectly instead.
Using Controller directly for custom constructors
If your algorithm has a different signature than TokenBucket(cache, limit, fillRate), use Controller directly:
import { Controller, SlidingWindowLogLua, RedisClient } from 'rate_limiter';
const algorithm = new SlidingWindowLogLua(redisClient, 60, 60_000);
const controller = new Controller(algorithm, (req) => req.ip ?? 'unknown');
app.get('/api', (req, res, next) => controller.rateLimit(req, res, next));CacheClient Interface
interface CacheClient {
get(key: string): Promise<string | null>;
set(key: string, value: string | number): Promise<void>;
increment(key: string): Promise<number>;
}get returns string | null (Redis returns strings; numbers are stored and retrieved as strings by Redis). Consumers should Number() the result when needed.
RedisClient
import { RedisClient } from 'rate_limiter';
import Redis from 'ioredis';
const client = new RedisClient(new Redis());Also exposes eval(script, keys, argv) for running Lua scripts. Used by the *Lua algorithm classes.
MockCacheClient (in-memory for tests)
import MockCacheClient from './test/mock_cache_client.js';
const cache = new MockCacheClient();Stores values in a Map<string, string>. No external dependencies needed.
API Reference
Algorithm (abstract class)
abstract class Algorithm {
constructor(protected cacheClient: CacheClient);
abstract execute(key: string): Promise<boolean>;
abstract getLimit(): number;
}| Method | Returns | Description |
|---|---|---|
| execute(key) | boolean | true = allowed, false = rate-limited |
| getLimit() | number | The configured limit (used for response headers) |
Algorithm classes
| Class | Constructor signature | Requires Redis |
|---|---|---|
| TokenBucket | (cache: CacheClient, limit: number, fillRate: number) | No |
| TokenBucketLua | (cache: RedisClient, limit: number, fillRate: number) | Yes |
| WindowFixedCount | (cache: CacheClient, limit: number, timeWindow: number) | No |
| WindowFixedCountLua | (cache: RedisClient, limit: number, timeWindow: number) | Yes |
| SlidingWindowLog | (cache: CacheClient, limit: number, windowMs: number) | No |
| SlidingWindowLogLua | (cache: RedisClient, limit: number, windowMs: number) | Yes |
createRateLimiter(options) — Express middleware factory
function createRateLimiter(options: RateLimiterOptions): RequestHandler;| Option | Type | Default | Description |
|---|---|---|---|
| limit | number | required | Max requests allowed in the window |
| fillRate | number | required | Refill rate (tokens/sec) — used by TokenBucket, ignored by others |
| cacheClient | CacheClient | required | Storage backend |
| algorithm | class constructor | TokenBucket | Algorithm class, e.g. TokenBucketLua, WindowFixedCountLua |
| keyExtractor | (req) => string | req.ip ?? 'unknown' | Extracts the rate-limit key from the Express request |
Controller — low-level Express integration
class Controller {
constructor(algorithm: Algorithm, keyExtractor?: (req: Request) => string);
rateLimit(req: Request, res: Response, next: NextFunction): void;
}Use when you need full control over the middleware lifecycle or when your algorithm has a non-standard constructor signature.
Running the Dev Server
# Requires Redis running on localhost:6379
npm run devTests
npm testUnit tests use MockCacheClient (in-memory). Integration tests use supertest against an Express app.
License
MIT
