Approach (brief)
Use an in-memory token-bucket-like fixed-window counter keyed by user id. Clear names, injectable clock for testability (avoids clock skew and makes unit tests deterministic). If user id missing, respond 400 or use an anonymous id strategy — here we reject with 400 to avoid accidental shared quotas.
Code (middleware + simple store):
ts
// rateLimiter.ts
import { Request, Response, NextFunction } from "express";
export type Clock = () => number; // millis
export interface RateLimiterOptions {
requestsPerMinute: number;
clock?: Clock;
}
type UserKey = string;
interface Window {
startMs: number;
count: number;
}
export class InMemoryRateLimiter {
private windows = new Map<UserKey, Window>();
private requestsPerMinute: number;
private clock: Clock;
private windowMs = 60_000;
constructor(opts: RateLimiterOptions) {
this.requestsPerMinute = opts.requestsPerMinute;
this.clock = opts.clock ?? (() => Date.now());
}
// returns true if allowed, false if rate-limited
allowRequest(userId: string): boolean {
const now = this.clock();
const win = this.windows.get(userId);
if (!win || now - win.startMs >= this.windowMs) {
// new window
this.windows.set(userId, { startMs: now, count: 1 });
return true;
}
if (win.count < this.requestsPerMinute) {
win.count += 1;
return true;
}
return false;
}
// helper for tests/maintenance
reset() {
this.windows.clear();
}
}
export function rateLimitMiddleware(limiter: InMemoryRateLimiter) {
return (req: Request, res: Response, next: NextFunction) => {
const userId = req.header("x-user-id");
if (!userId) {
res.status(400).json({ error: "Missing user id" });
return;
}
if (limiter.allowRequest(userId)) {
next();
} else {
res.status(429).json({ error: "Rate limit exceeded" });
}
};
}
Key concepts / reasoning
- Injected clock improves testability and avoids nondeterminism due to clock skew.
- Fixed 60s windows are simple and predictable; could upgrade to sliding window or leaky-bucket if smoother behavior is required.
- Rejecting missing user id prevents accidental quota sharing; an alternative is to assign an anonymous token per IP.
Complexity
- Time: O(1) per request (hashmap lookup).
- Space: O(U) where U is number of active users in current window.
Unit test example (Jest):
ts
// rateLimiter.test.ts
import { InMemoryRateLimiter } from "./rateLimiter";
test("allows up to N requests per minute and blocks the N+1", () => {
let now = 1000;
const clock = () => now;
const limiter = new InMemoryRateLimiter({ requestsPerMinute: 2, clock });
expect(limiter.allowRequest("user1")).toBe(true); // 1
expect(limiter.allowRequest("user1")).toBe(true); // 2
expect(limiter.allowRequest("user1")).toBe(false); // 3 blocked
// advance past window -> allowed again
now += 60_000;
expect(limiter.allowRequest("user1")).toBe(true);
});
Edge cases handled
- Clock injection mitigates clock-skew issues in tests and across clustered components.
- Missing user id returns 400 (explicit decision); alternative handling noted.
- Store reset provided for test lifecycle.