Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,88 @@
{
"name": "@suaveplan/rate-limiter",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Enterprise rate limiter: TokenBucket, SlidingWindow, FixedWindow algorithms; tenant multipliers; rule-based routing; pluggable storage; OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./errors": {
"import": { "types": "./dist/errors/rate-limiter-error.d.ts", "default": "./dist/errors/rate-limiter-error.js" },
"require": { "types": "./dist/errors/rate-limiter-error.d.ts", "default": "./dist/errors/rate-limiter-error.js" }
},
"./core/token-bucket/token-bucket": {
"import": { "types": "./dist/core/token-bucket/token-bucket.d.ts", "default": "./dist/core/token-bucket/token-bucket.js" },
"require": { "types": "./dist/core/token-bucket/token-bucket.d.ts", "default": "./dist/core/token-bucket/token-bucket.js" }
},
"./core/sliding-window/sliding-window": {
"import": { "types": "./dist/core/sliding-window/sliding-window.d.ts", "default": "./dist/core/sliding-window/sliding-window.js" },
"require": { "types": "./dist/core/sliding-window/sliding-window.d.ts", "default": "./dist/core/sliding-window/sliding-window.js" }
},
"./core/fixed-window/fixed-window": {
"import": { "types": "./dist/core/fixed-window/fixed-window.d.ts", "default": "./dist/core/fixed-window/fixed-window.js" },
"require": { "types": "./dist/core/fixed-window/fixed-window.d.ts", "default": "./dist/core/fixed-window/fixed-window.js" }
},
"./core/rate-limiter/rate-limiter": {
"import": { "types": "./dist/core/rate-limiter/rate-limiter.d.ts", "default": "./dist/core/rate-limiter/rate-limiter.js" },
"require": { "types": "./dist/core/rate-limiter/rate-limiter.d.ts", "default": "./dist/core/rate-limiter/rate-limiter.js" }
},
"./storage/adapter/adapter": {
"import": { "types": "./dist/storage/adapter/adapter.d.ts", "default": "./dist/storage/adapter/adapter.js" },
"require": { "types": "./dist/storage/adapter/adapter.d.ts", "default": "./dist/storage/adapter/adapter.js" }
},
"./storage/memory-storage/memory-storage": {
"import": { "types": "./dist/storage/memory-storage/memory-storage.d.ts", "default": "./dist/storage/memory-storage/memory-storage.js" },
"require": { "types": "./dist/storage/memory-storage/memory-storage.d.ts", "default": "./dist/storage/memory-storage/memory-storage.js" }
},
"./middleware/rate-limit-middleware/rate-limit-middleware": {
"import": { "types": "./dist/middleware/rate-limit-middleware/rate-limit-middleware.d.ts", "default": "./dist/middleware/rate-limit-middleware/rate-limit-middleware.js" },
"require": { "types": "./dist/middleware/rate-limit-middleware/rate-limit-middleware.d.ts", "default": "./dist/middleware/rate-limit-middleware/rate-limit-middleware.js" }
},
"./schemas": {
"import": { "types": "./dist/schemas/rate-limiter.schemas.d.ts", "default": "./dist/schemas/rate-limiter.schemas.js" },
"require": { "types": "./dist/schemas/rate-limiter.schemas.d.ts", "default": "./dist/schemas/rate-limiter.schemas.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,20 @@
/** Default key prefix applied to storage keys when no custom prefix is set. */
export const DEFAULT_KEY_PREFIX = "rl";
/** Default maximum entries in MemoryStorage before LRU eviction begins. */
export const DEFAULT_MEMORY_MAX_SIZE = 10_000;
/** Default window size in milliseconds (60 seconds). */
export const DEFAULT_WINDOW_MS = 60_000;
/** Default request limit per window when no config is provided. */
export const DEFAULT_LIMIT = 100;
/** OTel meter name used for all rate-limiter instruments. */
export const OTEL_METER_NAME = "rate_limiter";
/** Event type emitted when a key is rate-limited. */
export const EVENT_RATE_LIMIT_EXCEEDED = "rate:limit:exceeded" as const;
/** Event type emitted when a key's state is reset. */
export const EVENT_RATE_LIMIT_RESET = "rate:limit:reset" as const;

View File

@@ -0,0 +1,68 @@
# fixed-window
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the fixed-window rate-limiting algorithm. Time is divided into equal-length buckets of `windowMs` milliseconds. A counter per key tracks requests in the current bucket and resets to zero when a new bucket starts. This is the simplest algorithm — O(1) memory per key — suitable for coarse-grained limits where occasional boundary spikes (a burst at the end of one window plus the start of the next) are acceptable.
## Features
- O(1) counter per key with automatic window boundary reset
- Injectable `Clock` for deterministic tests
- `reset(key)` for admin and test operations
- `retryAfter` equals time until the current window ends
- `resetIn` reflects exact milliseconds until the next window
## Usage: Basic Example
```ts
import { FixedWindow } from "@suaveplan/rate-limiter";
const fw = new FixedWindow({ limit: 100, windowMs: 60_000 });
const result = fw.check("tenant:acme");
console.log(`${result.remaining} requests remaining`);
```
## Usage: Advanced Example
```ts
import { FixedWindow } from "@suaveplan/rate-limiter";
let t = 0;
const clock = () => t;
const fw = new FixedWindow({ limit: 2, windowMs: 1000, clock });
fw.check("k"); // count=1
fw.check("k"); // count=2
expect(fw.check("k").allowed).toBe(false);
// Advance to next window
t = 1000;
expect(fw.check("k").allowed).toBe(true); // counter reset
```
## API Reference
### `FixedWindow`
```ts
class FixedWindow {
constructor(options: FixedWindowOptions)
check(key: string): RateLimitResult
reset(key: string): void
}
```
### `FixedWindowOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `limit` | `number` | required | Max requests per window |
| `windowMs` | `number` | required | Window duration in milliseconds |
| `clock` | `Clock` | `Date.now` | Injectable time source |
## Implementation Notes
Window boundaries are computed as `Math.floor(now / windowMs) * windowMs`, so all callers sharing the same clock see synchronised window resets. The counter map retains stale entries indefinitely until `reset(key)` is called or the key reappears in a new window (at which point the stale entry is overwritten). This is safe because the window boundary check is always evaluated on access, never on eviction.

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { FixedWindow } from "./fixed-window.js";
describe("FixedWindow", () => {
let t: number;
let clock: () => number;
beforeEach(() => {
t = 0;
clock = () => t;
});
it("allows requests below limit", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 5, windowMs: 1000, clock });
const result = fw.check("k");
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
expect(result.limit).toBe(5);
});
it("rejects when limit reached", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 2, windowMs: 1000, clock });
fw.check("k");
fw.check("k");
const result = fw.check("k");
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
});
it("counter resets on window boundary", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 2, windowMs: 1000, clock });
fw.check("k");
fw.check("k");
expect(fw.check("k").allowed).toBe(false);
t = 1000; // next window
expect(fw.check("k").allowed).toBe(true);
});
it("resetIn reflects time until window end", { timeout: 5000 }, () => {
t = 500;
const fw = new FixedWindow({ limit: 5, windowMs: 1000, clock });
const result = fw.check("k");
expect(result.resetIn).toBe(500);
});
it("reset clears key state", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 1, windowMs: 1000, clock });
fw.check("k");
expect(fw.check("k").allowed).toBe(false);
fw.reset("k");
expect(fw.check("k").allowed).toBe(true);
});
it("separate keys are independent", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 1, windowMs: 1000, clock });
fw.check("a");
expect(fw.check("a").allowed).toBe(false);
expect(fw.check("b").allowed).toBe(true);
});
it("uses Date.now by default", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 5, windowMs: 1000 });
const result = fw.check("k");
expect(result.allowed).toBe(true);
});
it("stale window entry is replaced on new window", {
timeout: 5000,
}, () => {
const fw = new FixedWindow({ limit: 2, windowMs: 1000, clock });
fw.check("k");
fw.check("k");
t = 2000; // two windows later
const result = fw.check("k");
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(1);
});
it("rejected check does not increment counter", { timeout: 5000 }, () => {
const fw = new FixedWindow({ limit: 2, windowMs: 1000, clock });
fw.check("k");
fw.check("k");
fw.check("k"); // rejected
fw.check("k"); // rejected
t = 1000; // new window
const result = fw.check("k");
expect(result.remaining).toBe(1); // full 2 slots, used 1
});
});

View File

@@ -0,0 +1,64 @@
import type {
CounterState,
RateLimitResult,
} from "../../types/rate-limiter.types.js";
import type { FixedWindowOptions } from "./fixed-window.types.js";
/**
* Fixed-window rate-limiting algorithm.
*
* Maintains a counter per key that resets at the start of each fixed window.
* Lowest memory footprint of the three algorithms — suitable for coarse
* limits where boundary-spike artefacts are acceptable.
*/
export class FixedWindow {
protected readonly limit: number;
protected readonly windowMs: number;
protected readonly clock: () => number;
protected readonly counters: Map<string, CounterState>;
public constructor(options: FixedWindowOptions) {
this.limit = options.limit;
this.windowMs = options.windowMs;
this.clock = options.clock ?? (() => Date.now());
this.counters = new Map();
}
/**
* Record a request for `key` and return whether it is within the limit.
*/
public check(key: string): RateLimitResult {
const now = this.clock();
const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
let state = this.counters.get(key);
if (state === undefined || state.windowStart !== windowStart) {
state = { count: 0, windowStart };
this.counters.set(key, state);
}
const allowed = state.count < this.limit;
if (allowed) {
state.count++;
}
const remaining = Math.max(0, this.limit - state.count);
const resetIn = Math.max(0, windowStart + this.windowMs - now);
const result: RateLimitResult = {
allowed,
remaining,
limit: this.limit,
resetIn,
...(allowed ? {} : { retryAfter: resetIn }),
};
return result;
}
/**
* Reset the counter for `key`.
*/
public reset(key: string): void {
this.counters.delete(key);
}
}

View File

@@ -0,0 +1,11 @@
import type { Clock } from "@suaveplan/types";
/** Construction options for a `FixedWindow` instance. */
export interface FixedWindowOptions {
/** Maximum number of requests per window. */
limit: number;
/** Window duration in milliseconds. */
windowMs: number;
/** Injectable clock. Defaults to `Date.now`. */
clock?: Clock;
}

View File

@@ -0,0 +1,2 @@
export { FixedWindow } from "./fixed-window.js";
export type { FixedWindowOptions } from "./fixed-window.types.js";

View File

@@ -0,0 +1 @@
export { createRateLimiter } from "./rate-limiter.js";

View File

@@ -0,0 +1,96 @@
# rate-limiter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createRateLimiter` is the top-level orchestrator for `@suaveplan/rate-limiter`. It accepts a `RateLimiterOptions` object and returns a `RateLimiterInstance` that applies rule-based algorithm routing, tenant-specific multipliers, persistent state via a pluggable `RateLimitStorage` adapter, OTel counters and histograms, and optional event-bus integration.
Rules are evaluated in descending priority order — the first matching rule wins. A bypass rule returns an `allowed: true` result immediately without touching storage. If no rule matches, the `defaultConfig` is applied. Tenant multipliers scale the effective limit for matching `tenantId` values.
## Features
- Rule-based routing with priority ordering and five condition operators
- Bypass rules for privileged keys (admin accounts, monitoring agents)
- Tenant multipliers: `effectiveLimit = baseLimit * multiplier`
- Pluggable `RateLimitStorage` (defaults to in-memory LRU)
- `rate:limit:exceeded` and `rate:limit:reset` event bus emissions
- Structured logging via `LoggerLike`
- OTel: `rate_limiter.checks_total`, `rate_limiter.check_duration_ms`, `rate_limiter.exceeded_total`
## Usage: Basic Example
```ts
import { createRateLimiter } from "@suaveplan/rate-limiter";
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 60_000 },
});
const result = await limiter.check({ key: "user:42" });
if (!result.allowed) {
console.log(`Rate limited. Retry after ${result.retryAfter}ms`);
}
```
## Usage: Advanced Example
```ts
import { createRateLimiter, MemoryStorage } from "@suaveplan/rate-limiter";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus();
bus.on("rate:limit:exceeded", ({ key, resetIn }) => {
console.warn(`Key ${key} exceeded. Reset in ${resetIn}ms`);
});
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 60_000 },
storage: new MemoryStorage({ maxSize: 50_000 }),
rules: [
{
id: "admin-bypass",
limit: 0,
window: 60_000,
priority: 100,
bypass: true,
conditions: [{ field: "userId", operator: "in", value: ["admin-1"] }],
},
{
id: "strict-api",
limit: 10,
window: 1000,
priority: 50,
conditions: [{ field: "path", operator: "matches", value: "^/api/sensitive" }],
},
],
enableTenantLimits: true,
tenantMultipliers: { enterprise: 5 },
bus,
keyPrefix: "rl",
});
await limiter.reset("user:42");
const state = await limiter.getState("user:42");
```
## API Reference
### `createRateLimiter(options)`
Returns a `RateLimiterInstance`.
**Parameters**: `RateLimiterOptions` — see `rate-limiter.types.md`.
### `RateLimiterInstance`
| Method | Signature | Description |
|---|---|---|
| `check` | `(context: RateLimitContext) => Promise<RateLimitResult>` | Evaluate and record a request |
| `reset` | `(key: string) => Promise<void>` | Clear state and emit reset event |
| `getState` | `(key: string) => Promise<RateLimitState \| null>` | Read current persisted state |
## Implementation Notes
State synchronisation between the in-memory `SlidingWindow` instances and the storage adapter is done by loading persisted timestamps on each `check` and writing back after evaluation. This is correct for single-process deployments but not atomic for distributed Redis-backed setups — a Redis Lua script or atomic transaction would be required for strict distributed correctness. For in-memory (single-process) use, the `SlidingWindow` is the authoritative source and the storage adapter provides durability across restarts.

View File

@@ -0,0 +1,490 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { RateLimiterError } from "../../errors/rate-limiter-error.js";
import { MemoryStorage } from "../../storage/memory-storage/memory-storage.js";
import { createRateLimiter } from "./rate-limiter.js";
describe("createRateLimiter", () => {
let t: number;
let clock: () => number;
beforeEach(() => {
t = 1_000_000;
clock = () => t;
});
it("allows requests within limit", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
clock,
});
const result = await limiter.check({ key: "user:1" });
expect(result.allowed).toBe(true);
expect(result.limit).toBe(5);
});
it("rejects requests exceeding limit", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 2, window: 60_000 },
clock,
});
await limiter.check({ key: "u" });
await limiter.check({ key: "u" });
const result = await limiter.check({ key: "u" });
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeGreaterThan(0);
});
it("resets key state", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 1, window: 60_000 },
clock,
});
await limiter.check({ key: "u" });
await limiter.reset("u");
const result = await limiter.check({ key: "u" });
expect(result.allowed).toBe(true);
});
it("getState returns null for unknown key", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
clock,
});
expect(await limiter.getState("unknown")).toBeNull();
});
it("getState returns state after check", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
clock,
});
await limiter.check({ key: "u" });
const state = await limiter.getState("u");
expect(state).not.toBeNull();
expect(state?.algorithm).toBe("sliding-window");
});
it("applies bypass rule", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 1, window: 1000 },
rules: [
{
limit: 0,
window: 1000,
priority: 100,
bypass: true,
conditions: [
{ field: "userId", operator: "eq", value: "admin" },
],
},
],
clock,
});
// Exhaust limit for non-admin
await limiter.check({ key: "admin-key", userId: "admin" });
await limiter.check({ key: "admin-key", userId: "admin" });
const result = await limiter.check({
key: "admin-key",
userId: "admin",
});
expect(result.allowed).toBe(true); // bypass always passes
});
it("applies higher-priority rule first", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [
{
id: "strict",
limit: 1,
window: 1000,
priority: 50,
conditions: [
{ field: "path", operator: "eq", value: "/api" },
],
},
{
id: "looser",
limit: 10,
window: 1000,
priority: 10,
conditions: [
{ field: "path", operator: "eq", value: "/api" },
],
},
],
clock,
});
await limiter.check({ key: "k", path: "/api" });
const result = await limiter.check({ key: "k", path: "/api" });
// strict rule (limit=1) should have been matched first
expect(result.allowed).toBe(false);
});
it("applies tenant multiplier", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 10, window: 60_000 },
enableTenantLimits: true,
tenantMultipliers: { enterprise: 3 },
clock,
});
// enterprise tenant has effective limit = 30
for (let i = 0; i < 30; i++) {
const r = await limiter.check({
key: `ent-k-${i}`,
tenantId: "enterprise",
});
expect(r.allowed).toBe(true);
}
});
it("emits rate:limit:exceeded event on bus", {
timeout: 5000,
}, async () => {
const events: unknown[] = [];
const bus = {
on: (_: string, _l: unknown) => () => {},
emit: (_: string, payload: unknown) => {
events.push(payload);
},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 1, window: 1000 },
bus,
clock,
});
await limiter.check({ key: "k" });
await limiter.check({ key: "k" });
expect(events.length).toBeGreaterThanOrEqual(1);
});
it("emits rate:limit:reset event on bus", { timeout: 5000 }, async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const bus = {
on: (_: string, _l: unknown) => () => {},
emit: (type: string, payload: unknown) => {
events.push({ type, payload });
},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
bus,
clock,
});
await limiter.reset("k");
expect(events.some((e) => e.type === "rate:limit:reset")).toBe(true);
});
it("throws KEY_MISSING when key is empty", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
clock,
});
await expect(limiter.check({ key: "" })).rejects.toBeInstanceOf(
RateLimiterError
);
});
it("uses custom storage", { timeout: 5000 }, async () => {
const storage = new MemoryStorage({ clock });
const limiter = createRateLimiter({
defaultConfig: { limit: 3, window: 1000 },
storage,
clock,
});
await limiter.check({ key: "u" });
expect(storage.size).toBeGreaterThan(0);
});
it("rule with no conditions matches everything", {
timeout: 5000,
}, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [{ limit: 1, window: 1000, priority: 5 }],
clock,
});
await limiter.check({ key: "k" });
const result = await limiter.check({ key: "k" });
expect(result.allowed).toBe(false);
});
it("condition operator: neq", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [
{
limit: 1,
window: 1000,
priority: 5,
conditions: [
{ field: "method", operator: "neq", value: "GET" },
],
},
],
clock,
});
// POST matches neq GET → strict rule applies
await limiter.check({ key: "post-k", method: "POST" });
const r = await limiter.check({ key: "post-k", method: "POST" });
expect(r.allowed).toBe(false);
// GET does not match neq GET → default rule (limit=100) applies
const getResult = await limiter.check({ key: "get-k", method: "GET" });
expect(getResult.allowed).toBe(true);
});
it("condition operator: nin", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [
{
limit: 1,
window: 1000,
priority: 5,
conditions: [
{
field: "tenantId",
operator: "nin",
value: ["allowed-1", "allowed-2"],
},
],
},
],
clock,
});
await limiter.check({ key: "x", tenantId: "other" });
const r = await limiter.check({ key: "x", tenantId: "other" });
expect(r.allowed).toBe(false);
const r2 = await limiter.check({ key: "y", tenantId: "allowed-1" });
expect(r2.allowed).toBe(true);
});
it("condition operator: matches", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [
{
limit: 1,
window: 1000,
priority: 5,
conditions: [
{ field: "path", operator: "matches", value: "^/api/" },
],
},
],
clock,
});
await limiter.check({ key: "api-k", path: "/api/users" });
const r = await limiter.check({ key: "api-k", path: "/api/users" });
expect(r.allowed).toBe(false);
});
it("throws STORAGE_ERROR when check storage throws", {
timeout: 5000,
}, async () => {
const storage = {
get: async (_key: string) => {
throw new Error("storage down");
},
set: async () => {},
delete: async () => {},
clear: async () => {},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
storage,
clock,
});
await expect(limiter.check({ key: "k" })).rejects.toBeInstanceOf(
RateLimiterError
);
});
it("throws STORAGE_ERROR when reset storage throws", {
timeout: 5000,
}, async () => {
const storage = {
get: async () => null,
set: async () => {},
delete: async (_key: string) => {
throw new Error("storage down");
},
clear: async () => {},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
storage,
clock,
});
await expect(limiter.reset("k")).rejects.toBeInstanceOf(
RateLimiterError
);
});
it("throws STORAGE_ERROR when getState storage throws", {
timeout: 5000,
}, async () => {
const storage = {
get: async (_key: string) => {
throw new Error("storage down");
},
set: async () => {},
delete: async () => {},
clear: async () => {},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
storage,
clock,
});
await expect(limiter.getState("k")).rejects.toBeInstanceOf(
RateLimiterError
);
});
it("re-throws RateLimiterError from check", { timeout: 5000 }, async () => {
const ratErr = new RateLimiterError(
"RATE.STORAGE_ERROR",
"rate.storage.failed"
);
const storage = {
get: async (_key: string) => {
throw ratErr;
},
set: async () => {},
delete: async () => {},
clear: async () => {},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
storage,
clock,
});
await expect(limiter.check({ key: "k" })).rejects.toBe(ratErr);
});
it("matches operator returns false for non-string raw", {
timeout: 5000,
}, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
rules: [
{
limit: 1,
window: 1000,
conditions: [
{ field: "userId", operator: "matches", value: ".*" },
],
},
],
clock,
});
const r = await limiter.check({
key: "k",
userId: 42 as unknown as string,
});
expect(r.allowed).toBe(true);
expect(r.limit).toBe(5);
});
it("sorts rules without explicit priority", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [
{ limit: 1, window: 1000 },
{ limit: 2, window: 1000 },
],
clock,
});
const r = await limiter.check({ key: "k" });
expect(r.allowed).toBe(true);
});
it("bypass rule without tenantId", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
rules: [{ limit: 100, window: 1000, bypass: true }],
clock,
});
const r = await limiter.check({ key: "k" });
expect(r.allowed).toBe(true);
});
it("tenantMultipliers without entry for tenantId", {
timeout: 5000,
}, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
enableTenantLimits: true,
tenantMultipliers: { other: 2 },
clock,
});
const r = await limiter.check({ key: "k", tenantId: "missing" });
expect(r.limit).toBe(5);
});
it("emits exceeded event without tenantId", { timeout: 5000 }, async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const bus = {
emit(type: string, payload: unknown) {
events.push({ type, payload });
},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 1, window: 1000 },
bus,
clock,
});
await limiter.check({ key: "k" });
await limiter.check({ key: "k" });
const exceeded = events.find((e) => e.type === "rate:limit:exceeded");
expect(exceeded).toBeDefined();
expect(
(exceeded?.payload as { tenantId?: string }).tenantId
).toBeUndefined();
});
it("bypass rule with tenantId", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
rules: [{ limit: 100, window: 1000, bypass: true }],
clock,
});
const r = await limiter.check({ key: "k", tenantId: "acme" });
expect(r.allowed).toBe(true);
});
it("emits exceeded event with tenantId", { timeout: 5000 }, async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const bus = {
emit(type: string, payload: unknown) {
events.push({ type, payload });
},
};
const limiter = createRateLimiter({
defaultConfig: { limit: 1, window: 1000 },
bus,
clock,
});
await limiter.check({ key: "k", tenantId: "acme" });
await limiter.check({ key: "k", tenantId: "acme" });
const exceeded = events.find((e) => e.type === "rate:limit:exceeded");
expect((exceeded?.payload as { tenantId?: string }).tenantId).toBe(
"acme"
);
});
it("reset leaves unrelated rule windows intact", {
timeout: 5000,
}, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
clock,
});
await limiter.check({ key: "a" });
await limiter.check({ key: "b" });
await limiter.reset("a");
const r = await limiter.check({ key: "b" });
expect(r.remaining).toBeLessThan(5);
});
});

View File

@@ -0,0 +1,279 @@
import { metrics } from "@opentelemetry/api";
import {
RATE_CODES,
RateLimiterError,
} from "../../errors/rate-limiter-error.js";
import type { RateLimitStorage } from "../../storage/adapter/adapter.js";
import { MemoryStorage } from "../../storage/memory-storage/memory-storage.js";
import type {
RateLimitAlgorithm,
RateLimitCondition,
RateLimitContext,
RateLimiterEvents,
RateLimiterInstance,
RateLimiterOptions,
RateLimitResult,
RateLimitRule,
RateLimitState,
} from "../../types/rate-limiter.types.js";
import { SlidingWindow } from "../sliding-window/sliding-window.js";
const meter = metrics.getMeter("rate_limiter");
const checksTotal = meter.createCounter("rate_limiter.checks_total");
const checkDuration = meter.createHistogram("rate_limiter.check_duration_ms");
const exceededTotal = meter.createCounter("rate_limiter.exceeded_total");
function evaluateCondition(
condition: RateLimitCondition,
context: RateLimitContext
): boolean {
const raw = (context as unknown as Record<string, unknown>)[
condition.field
];
const { operator, value } = condition;
switch (operator) {
case "eq":
return raw === value;
case "neq":
return raw !== value;
case "in":
return Array.isArray(value) && value.includes(raw);
case "nin":
return Array.isArray(value) && !value.includes(raw);
case "matches":
return typeof raw === "string" && typeof value === "string"
? new RegExp(value).test(raw)
: false;
}
}
function ruleMatches(rule: RateLimitRule, context: RateLimitContext): boolean {
if (!rule.conditions || rule.conditions.length === 0) return true;
return rule.conditions.every((c) => evaluateCondition(c, context));
}
function buildStorageKey(prefix: string, key: string): string {
return `${prefix}:${key}`;
}
/**
* Create a `RateLimiterInstance` that applies rule-based algorithm routing,
* tenant multipliers, and OTel instrumentation.
*/
export function createRateLimiter(
options: RateLimiterOptions
): RateLimiterInstance {
const {
defaultConfig,
rules = [],
enableTenantLimits = false,
tenantMultipliers = {},
keyPrefix = "rl",
clock = () => Date.now(),
bus,
logger,
} = options;
const storage: RateLimitStorage =
options.storage ?? new MemoryStorage({ clock });
// Sort rules descending by priority (highest first)
const sortedRules = [...rules].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0)
);
// Per-rule sliding-window instances keyed by rule id/index
const ruleWindows = new Map<string, SlidingWindow>();
function getRuleWindow(
ruleKey: string,
limit: number,
windowMs: number
): SlidingWindow {
let sw = ruleWindows.get(ruleKey);
if (sw === undefined) {
sw = new SlidingWindow({ limit, windowMs, clock });
ruleWindows.set(ruleKey, sw);
}
return sw;
}
async function check(context: RateLimitContext): Promise<RateLimitResult> {
if (!context.key) {
throw new RateLimiterError(
RATE_CODES.KEY_MISSING,
"rate.key.missing",
{ context: { provided: context.key } }
);
}
const start = clock();
const tenantId = context.tenantId;
// Find first matching rule
let matchedRule: RateLimitRule | undefined;
let ruleKey = "__default__";
for (let i = 0; i < sortedRules.length; i++) {
const rule = sortedRules[i];
if (rule !== undefined && ruleMatches(rule, context)) {
matchedRule = rule;
ruleKey = rule.id ?? `rule_${i}`;
break;
}
}
// Bypass
if (matchedRule?.bypass === true) {
checksTotal.add(1, {
algorithm: "bypass",
allowed: "true",
...(tenantId !== undefined ? { tenant_id: tenantId } : {}),
});
return {
allowed: true,
remaining: matchedRule.limit,
limit: matchedRule.limit,
resetIn: 0,
};
}
const config = matchedRule ?? defaultConfig;
let effectiveLimit = config.limit;
if (enableTenantLimits && tenantId !== undefined) {
const multiplier = tenantMultipliers[tenantId];
if (multiplier !== undefined) {
effectiveLimit = Math.round(effectiveLimit * multiplier);
}
}
const algorithm: RateLimitAlgorithm = "sliding-window";
const storageKey = buildStorageKey(keyPrefix, context.key);
let result: RateLimitResult;
try {
// Load existing state from storage
const existing = await storage.get(storageKey);
const sw = getRuleWindow(
`${ruleKey}:${storageKey}`,
effectiveLimit,
config.window
);
// If we have persisted state, restore timestamps into the window
if (
existing?.algorithm === "sliding-window" &&
"requests" in existing.state
) {
const persistedRequests = existing.state.requests;
const cutoff = clock() - config.window;
const live = persistedRequests.filter((ts) => ts >= cutoff);
// Rebuild window with live timestamps — we patch internal state
const internalWindow = (
sw as unknown as { windows: Map<string, number[]> }
).windows;
internalWindow.set(storageKey, live);
}
result = sw.check(storageKey);
// Persist updated state
const internalWindow = (
sw as unknown as { windows: Map<string, number[]> }
).windows;
const requests = internalWindow.get(storageKey) as number[];
const newState: RateLimitState = {
key: storageKey,
algorithm,
state: { requests: [...requests] },
config: { ...config, limit: effectiveLimit },
updatedAt: clock(),
};
await storage.set(storageKey, newState, config.window);
} catch (err) {
if (err instanceof RateLimiterError) throw err;
throw new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed",
{ cause: err as Error }
);
}
const duration = clock() - start;
checksTotal.add(1, {
algorithm,
allowed: String(result.allowed),
...(tenantId !== undefined ? { tenant_id: tenantId } : {}),
});
checkDuration.record(duration);
if (!result.allowed) {
exceededTotal.add(1, {
key_prefix: keyPrefix,
...(tenantId !== undefined ? { tenant_id: tenantId } : {}),
});
logger?.warn("Rate limit exceeded", {
key: context.key,
tenantId,
remaining: result.remaining,
resetIn: result.resetIn,
});
if (bus) {
const payload: RateLimiterEvents["rate:limit:exceeded"] = {
key: context.key,
remaining: result.remaining,
resetIn: result.resetIn,
...(tenantId !== undefined ? { tenantId } : {}),
};
(bus as { emit(type: string, payload: unknown): void }).emit(
"rate:limit:exceeded",
payload
);
}
}
return result;
}
async function reset(key: string): Promise<void> {
const storageKey = buildStorageKey(keyPrefix, key);
try {
await storage.delete(storageKey);
// Clear in-memory window state for all rule variants
for (const [k, sw] of ruleWindows) {
if (k.endsWith(`:${storageKey}`)) {
sw.reset(storageKey);
}
}
} catch (err) {
throw new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed",
{ cause: err as Error }
);
}
if (bus) {
(bus as { emit(type: string, payload: unknown): void }).emit(
"rate:limit:reset",
{ key }
);
}
}
async function getState(key: string): Promise<RateLimitState | null> {
const storageKey = buildStorageKey(keyPrefix, key);
try {
return await storage.get(storageKey);
} catch (err) {
throw new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed",
{ cause: err as Error }
);
}
}
return { check, reset, getState };
}

View File

@@ -0,0 +1,14 @@
export type {
RateLimitCondition,
RateLimitConfig,
RateLimitContext,
RateLimitedPayload,
RateLimiterEvents,
RateLimiterInstance,
RateLimiterOptions,
RateLimitResetPayload,
RateLimitResult,
RateLimitRule,
RateLimitState,
TenantRateLimitConfig,
} from "../../types/rate-limiter.types.js";

View File

@@ -0,0 +1,2 @@
export { SlidingWindow } from "./sliding-window.js";
export type { SlidingWindowOptions } from "./sliding-window.types.js";

View File

@@ -0,0 +1,73 @@
# sliding-window
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the sliding-window rate-limiting algorithm. Each key maintains a sorted array of request timestamps. On every `check`, timestamps older than `now - windowMs` are pruned and the remaining count is compared against `limit`. This produces a smooth rate distribution without the periodic boundary spikes that plague fixed-window counters.
The sliding window trades memory (O(limit) timestamps per key) for accuracy. It is the default algorithm used by `createRateLimiter`.
## Features
- Per-key timestamp arrays with automatic expiry pruning
- Injectable `Clock` for deterministic tests
- `reset(key)` for admin and test operations
- `retryAfter` equals time until the oldest request in the window expires
## Usage: Basic Example
```ts
import { SlidingWindow } from "@suaveplan/rate-limiter";
const sw = new SlidingWindow({ limit: 5, windowMs: 1000 });
const result = sw.check("user:1");
if (!result.allowed) {
console.log(`Retry in ${result.retryAfter}ms`);
}
```
## Usage: Advanced Example
```ts
import { SlidingWindow } from "@suaveplan/rate-limiter";
let t = 1_000_000;
const clock = () => t;
const sw = new SlidingWindow({ limit: 3, windowMs: 1000, clock });
// Fill limit
sw.check("user:1");
sw.check("user:1");
sw.check("user:1");
expect(sw.check("user:1").allowed).toBe(false);
// Advance past window — all old entries expire
t += 1001;
expect(sw.check("user:1").allowed).toBe(true);
```
## API Reference
### `SlidingWindow`
```ts
class SlidingWindow {
constructor(options: SlidingWindowOptions)
check(key: string): RateLimitResult
reset(key: string): void
}
```
### `SlidingWindowOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `limit` | `number` | required | Max requests in `windowMs` |
| `windowMs` | `number` | required | Window duration in milliseconds |
| `clock` | `Clock` | `Date.now` | Injectable time source |
## Implementation Notes
Timestamps are stored in chronological order. The prune step uses a linear scan from the start — acceptable because the splice is O(k) where k is the number of expired entries. For very high-frequency keys or very large windows, a circular-buffer implementation would be preferable. The `check` call only records a timestamp when `allowed === true`, so rejected requests do not consume capacity.

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { SlidingWindow } from "./sliding-window.js";
describe("SlidingWindow", () => {
let t: number;
let clock: () => number;
beforeEach(() => {
t = 1_000_000;
clock = () => t;
});
it("allows requests below limit", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 5, windowMs: 1000, clock });
const result = sw.check("k1");
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
expect(result.limit).toBe(5);
});
it("rejects when limit reached", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 2, windowMs: 1000, clock });
sw.check("k");
sw.check("k");
const result = sw.check("k");
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
it("prunes expired entries after window passes", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 2, windowMs: 1000, clock });
sw.check("k");
sw.check("k");
t += 1001;
const result = sw.check("k");
expect(result.allowed).toBe(true);
});
it("partial window expiry works correctly", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 3, windowMs: 1000, clock });
sw.check("k"); // at t=1_000_000
t += 600;
sw.check("k"); // at t=1_000_600
sw.check("k"); // at t=1_000_600
// now at t=1_000_600, count=3, limit reached
expect(sw.check("k").allowed).toBe(false);
t += 401; // t=1_001_001 - first entry (t=1_000_000) is now outside window of 1000ms
// window = [1_001_001 - 1000, 1_001_001] = [1_000_001, 1_001_001]
// entries at 1_000_600 and 1_000_600 are still inside
expect(sw.check("k").allowed).toBe(true);
});
it("reset clears key state", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 1, windowMs: 1000, clock });
sw.check("k");
expect(sw.check("k").allowed).toBe(false);
sw.reset("k");
expect(sw.check("k").allowed).toBe(true);
});
it("separate keys are independent", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 1, windowMs: 1000, clock });
sw.check("a");
expect(sw.check("a").allowed).toBe(false);
expect(sw.check("b").allowed).toBe(true);
});
it("resetIn is 0 on a new key with no prior entries", {
timeout: 5000,
}, () => {
// Before first check, the window has no timestamps, so resetIn should be
// windowMs for the newly added timestamp (entry at t, expiry = t + windowMs)
const sw = new SlidingWindow({ limit: 5, windowMs: 1000, clock });
const result = sw.check("k");
// After first allowed check, oldest = t, resetIn = t + 1000 - t = 1000
expect(result.resetIn).toBe(1000);
});
it("resetIn reflects oldest entry expiry", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 5, windowMs: 1000, clock });
sw.check("k"); // recorded at t
t += 200;
const result = sw.check("k");
// oldest is at t-200, so resetIn = (t-200 + 1000) - (t) = 800
expect(result.resetIn).toBe(800);
});
it("uses Date.now by default", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 5, windowMs: 1000 });
const result = sw.check("k");
expect(result.allowed).toBe(true);
});
it("rejected check does not consume capacity", { timeout: 5000 }, () => {
const sw = new SlidingWindow({ limit: 2, windowMs: 1000, clock });
sw.check("k");
sw.check("k");
sw.check("k"); // rejected
sw.check("k"); // still rejected
t += 1001;
// after reset, should have 2 slots again not 0
const r = sw.check("k");
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(1);
});
});

View File

@@ -0,0 +1,76 @@
import type { RateLimitResult } from "../../types/rate-limiter.types.js";
import type { SlidingWindowOptions } from "./sliding-window.types.js";
/**
* Sliding-window rate-limiting algorithm.
*
* Maintains an array of request timestamps per key. On each `check`, entries
* older than `now - windowMs` are pruned before evaluating the limit. This
* provides a smooth rate distribution compared to fixed-window counters.
*/
export class SlidingWindow {
protected readonly limit: number;
protected readonly windowMs: number;
protected readonly clock: () => number;
protected readonly windows: Map<string, number[]>;
public constructor(options: SlidingWindowOptions) {
this.limit = options.limit;
this.windowMs = options.windowMs;
this.clock = options.clock ?? (() => Date.now());
this.windows = new Map();
}
/**
* Record a request for `key` and return whether it is within the limit.
*/
public check(key: string): RateLimitResult {
const now = this.clock();
const cutoff = now - this.windowMs;
let timestamps = this.windows.get(key);
if (timestamps === undefined) {
timestamps = [];
this.windows.set(key, timestamps);
}
// Prune expired timestamps
let i = 0;
while (i < timestamps.length && (timestamps[i] as number) < cutoff) {
i++;
}
if (i > 0) {
timestamps.splice(0, i);
}
const count = timestamps.length;
const allowed = count < this.limit;
if (allowed) {
timestamps.push(now);
}
const remaining = Math.max(0, this.limit - timestamps.length);
const oldest = timestamps[0];
const resetIn =
oldest !== undefined
? Math.max(0, oldest + this.windowMs - now)
: 0;
const result: RateLimitResult = {
allowed,
remaining,
limit: this.limit,
resetIn,
...(allowed ? {} : { retryAfter: resetIn }),
};
return result;
}
/**
* Clear all recorded timestamps for `key`.
*/
public reset(key: string): void {
this.windows.delete(key);
}
}

View File

@@ -0,0 +1,11 @@
import type { Clock } from "@suaveplan/types";
/** Construction options for a `SlidingWindow` instance. */
export interface SlidingWindowOptions {
/** Maximum number of requests allowed within `windowMs`. */
limit: number;
/** Window duration in milliseconds. */
windowMs: number;
/** Injectable clock. Defaults to `Date.now`. */
clock?: Clock;
}

View File

@@ -0,0 +1,2 @@
export { TokenBucket } from "./token-bucket.js";
export type { TokenBucketOptions } from "./token-bucket.types.js";

View File

@@ -0,0 +1,75 @@
# token-bucket
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the token bucket rate-limiting algorithm. Tokens accumulate continuously at `refillRate` tokens per millisecond up to `capacity`. Each `consume(n)` call checks whether enough tokens are available, deducts them if so, and returns a `RateLimitResult` indicating whether the request was allowed. Requests that arrive when the bucket is empty are rejected without penalising the bucket state.
The token bucket is ideal for workloads that benefit from burst tolerance: a burst of requests up to `capacity` is permitted as long as the long-run rate does not exceed `refillRate * 1000` requests per second.
## Features
- Continuous token refill based on elapsed wall-clock time
- Injectable `Clock` for deterministic tests without `sleep`
- `available` and `capacity` read-only getters
- Manual `refill(tokens?)` for test setup and admin operations
- `RateLimitResult` with `retryAfter` computed from deficit tokens
## Usage: Basic Example
```ts
import { TokenBucket } from "@suaveplan/rate-limiter";
const bucket = new TokenBucket({ capacity: 10, refillRate: 0.1 }); // 100 req/s
const result = bucket.consume();
if (!result.allowed) {
console.log(`Retry in ${result.retryAfter}ms`);
}
```
## Usage: Advanced Example
```ts
import { TokenBucket } from "@suaveplan/rate-limiter";
let t = Date.now();
const clock = () => t;
const bucket = new TokenBucket({ capacity: 5, refillRate: 0.005, clock });
// Drain the bucket
for (let i = 0; i < 5; i++) bucket.consume();
expect(bucket.available).toBe(0);
// Advance 1 second — refillRate 0.005 * 1000ms = 5 tokens
t += 1000;
expect(bucket.available).toBe(5);
```
## API Reference
### `TokenBucket`
```ts
class TokenBucket {
constructor(options: TokenBucketOptions)
consume(tokens?: number): RateLimitResult
refill(tokens?: number): void
readonly available: number
readonly capacity: number
}
```
### `TokenBucketOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `capacity` | `number` | required | Max tokens the bucket can hold |
| `refillRate` | `number` | required | Tokens added per millisecond |
| `initialTokens` | `number` | `capacity` | Starting token count |
| `clock` | `Clock` | `Date.now` | Injectable time source |
## Implementation Notes
Refill is performed lazily on every `consume()` and on the `available` getter. The `performRefill` helper uses `Math.min(capacity, available + elapsed * rate)` to clamp at capacity. The `retryAfter` in a rejected result is the ceiling of `deficit / refillRate`, giving the caller a precise time to retry.

View File

@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { TokenBucket } from "./token-bucket.js";
describe("TokenBucket", () => {
let t: number;
let clock: () => number;
beforeEach(() => {
t = 1_000_000;
clock = () => t;
});
it("starts at full capacity", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
clock,
});
expect(bucket.available).toBe(10);
expect(bucket.capacity).toBe(10);
});
it("respects initialTokens", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
initialTokens: 3,
clock,
});
expect(bucket.available).toBe(3);
});
it("clamps initialTokens to capacity", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 5,
refillRate: 0.01,
initialTokens: 100,
clock,
});
expect(bucket.available).toBe(5);
});
it("allows consume when tokens available", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 5,
refillRate: 0.01,
clock,
});
const result = bucket.consume();
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
expect(result.limit).toBe(5);
});
it("rejects consume when tokens exhausted", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 2,
refillRate: 0.001,
initialTokens: 0,
clock,
});
const result = bucket.consume();
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeGreaterThan(0);
});
it("available never goes below 0", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 3,
refillRate: 0.001,
initialTokens: 1,
clock,
});
bucket.consume();
bucket.consume(); // should fail but not go negative
expect(bucket.available).toBeGreaterThanOrEqual(0);
});
it("available never exceeds capacity", { timeout: 5000 }, () => {
const bucket = new TokenBucket({ capacity: 5, refillRate: 100, clock });
t += 10_000; // would add 1_000_000 tokens
expect(bucket.available).toBe(5);
});
it("refills based on elapsed time", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
initialTokens: 0,
clock,
});
t += 500; // 0.01 tokens/ms * 500ms = 5 tokens
expect(bucket.available).toBe(5);
});
it("manual refill to capacity", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
initialTokens: 2,
clock,
});
bucket.refill();
expect(bucket.available).toBe(10);
});
it("manual refill with token count", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
initialTokens: 2,
clock,
});
bucket.refill(3);
expect(bucket.available).toBe(5);
});
it("manual refill clamps at capacity", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 5,
refillRate: 0.01,
clock,
});
bucket.refill(100);
expect(bucket.available).toBe(5);
});
it("consume multiple tokens", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
clock,
});
const result = bucket.consume(3);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(7);
});
it("result includes resetIn", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
initialTokens: 5,
clock,
});
const result = bucket.consume();
expect(result.resetIn).toBeGreaterThanOrEqual(0);
});
it("uses Date.now by default", { timeout: 5000 }, () => {
const bucket = new TokenBucket({ capacity: 5, refillRate: 0.01 });
expect(bucket.available).toBeLessThanOrEqual(5);
expect(bucket.available).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,93 @@
import type { RateLimitResult } from "../../types/rate-limiter.types.js";
import type { TokenBucketOptions } from "./token-bucket.types.js";
/**
* Token bucket rate-limiting algorithm.
*
* Tokens accumulate at `refillRate` tokens per millisecond up to `capacity`.
* Each `consume(n)` call draws `n` tokens; if insufficient tokens are
* available the request is rejected without modifying state.
*/
export class TokenBucket {
protected readonly _capacity: number;
protected readonly refillRate: number;
protected readonly clock: () => number;
protected _available: number;
protected lastRefill: number;
public constructor(options: TokenBucketOptions) {
this._capacity = options.capacity;
this.refillRate = options.refillRate;
this.clock = options.clock ?? (() => Date.now());
this._available =
options.initialTokens !== undefined
? Math.min(options.initialTokens, options.capacity)
: options.capacity;
this.lastRefill = this.clock();
}
/** Current available token count (after lazy refill). */
public get available(): number {
this.performRefill();
return this._available;
}
/** Maximum token capacity. */
public get capacity(): number {
return this._capacity;
}
/**
* Attempt to consume `tokens` (default 1) from the bucket.
*
* @returns `RateLimitResult` with `allowed === true` when tokens are available.
*/
public consume(tokens = 1): RateLimitResult {
this.performRefill();
const allowed = this._available >= tokens;
if (allowed) {
this._available -= tokens;
}
const remaining = Math.max(0, this._available);
const tokensNeeded = allowed ? 0 : tokens - this._available;
const resetIn = Math.ceil(
(this._capacity - this._available) / this.refillRate
);
const result: RateLimitResult = {
allowed,
remaining,
limit: this._capacity,
resetIn: Math.max(0, resetIn),
...(allowed
? {}
: { retryAfter: Math.ceil(tokensNeeded / this.refillRate) }),
};
return result;
}
/**
* Manually add `tokens` to the bucket (up to capacity).
* If `tokens` is omitted, refills to capacity.
*/
public refill(tokens?: number): void {
if (tokens === undefined) {
this._available = this._capacity;
this.lastRefill = this.clock();
} else {
this._available = Math.min(
this._capacity,
this._available + tokens
);
}
}
protected performRefill(): void {
const now = this.clock();
const elapsed = Math.max(0, now - this.lastRefill);
const added = elapsed * this.refillRate;
if (added > 0) {
this._available = Math.min(this._capacity, this._available + added);
this.lastRefill = now;
}
}
}

View File

@@ -0,0 +1,13 @@
import type { Clock } from "@suaveplan/types";
/** Construction options for a `TokenBucket` instance. */
export interface TokenBucketOptions {
/** Maximum number of tokens the bucket can hold. */
capacity: number;
/** Number of tokens added per millisecond during refill. */
refillRate: number;
/** Initial token count. Defaults to `capacity`. */
initialTokens?: number;
/** Injectable clock. Defaults to `Date.now`. */
clock?: Clock;
}

View File

@@ -0,0 +1,5 @@
export {
RATE_CODES,
RateLimiterError,
type RateLimiterErrorCode,
} from "./rate-limiter-error.js";

View File

@@ -0,0 +1,97 @@
# rate-limiter-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error class and stable error-code registry for `@suaveplan/rate-limiter`. All runtime failures in this package throw a `RateLimiterError`, which extends `SuaveplanError` from `@suaveplan/error`, ensuring a consistent error shape with code, context, cause-chain traversal, OTel span-event adapter, and i18n message resolution across the entire Suaveplan.
Using `defineCodes("RATE", [...])` pins every error identifier to a fully-qualified string such as `"RATE.STORAGE_ERROR"` that survives tree-shaking, minification, and code-split boundaries — callers can discriminate errors by `error.code` without importing the original source file.
## Features
- Four stable error codes: `STORAGE_ERROR`, `INVALID_CONFIG`, `KEY_MISSING`, `STATE_CORRUPT`
- Full TypeScript narrowing via the `RateLimiterErrorCode` union type
- `instanceof RateLimiterError` works across ESM / CJS boundaries
- Cause-chain support via `options.cause`
- Structured context attachment via `options.context`
- Injectable clock for deterministic `timestamp` in tests
## Usage: Basic Example
```ts
import { RateLimiterError, RATE_CODES } from "@suaveplan/rate-limiter/errors";
throw new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed",
{ context: { key: "user:123" } }
);
```
## Usage: Advanced Example
```ts
import { RateLimiterError, RATE_CODES } from "@suaveplan/rate-limiter/errors";
import { walkCauses } from "@suaveplan/error";
try {
await storage.get("user:123");
} catch (err) {
throw new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed",
{
context: { key: "user:123", operation: "get" },
...(err instanceof Error ? { cause: err } : {}),
}
);
}
// Discriminate by code at a higher layer:
try {
await rateLimiter.check(ctx);
} catch (err) {
if (err instanceof RateLimiterError && err.code === RATE_CODES.KEY_MISSING) {
// handle missing key
}
for (const cause of walkCauses(err)) {
console.error(cause.message);
}
}
```
## API Reference
### `RATE_CODES`
`Record` mapping reason names to fully-qualified code strings:
| Key | Value |
|---|---|
| `STORAGE_ERROR` | `"RATE.STORAGE_ERROR"` |
| `INVALID_CONFIG` | `"RATE.INVALID_CONFIG"` |
| `KEY_MISSING` | `"RATE.KEY_MISSING"` |
| `STATE_CORRUPT` | `"RATE.STATE_CORRUPT"` |
### `RateLimiterErrorCode`
Union type of all values in `RATE_CODES`.
### `RateLimiterError`
Extends `SuaveplanError<RateLimiterErrorCode>`.
**Constructor**: `(code: RateLimiterErrorCode, messageKey: string, options?: SuaveplanErrorOptions) => RateLimiterError`
- `code` — one of the `RATE_CODES` values
- `messageKey` — i18n key resolved by the global message resolver
- `options.context` — structured key-value context attached to the error
- `options.cause` — underlying error preserved in the cause chain
- `options.traceId` — OTel trace correlation id
- `options.clock` — injectable time source (defaults to `Date.now`)
## Implementation Notes
`defineCodes` returns a frozen object, so `RATE_CODES` is immutable at runtime. The `RateLimiterError` constructor sets `this.name = "RateLimiterError"` explicitly so that serialised stack traces identify the class correctly even when the prototype chain is obscured by bundlers.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RATE_CODES, RateLimiterError } from "./rate-limiter-error.js";
describe("RateLimiterError", () => {
it("creates error with correct code", { timeout: 5000 }, () => {
const err = new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed"
);
expect(err.code).toBe("RATE.STORAGE_ERROR");
expect(err.name).toBe("RateLimiterError");
expect(err).toBeInstanceOf(RateLimiterError);
expect(err).toBeInstanceOf(Error);
});
it("sets all four codes correctly", { timeout: 5000 }, () => {
expect(RATE_CODES.STORAGE_ERROR).toBe("RATE.STORAGE_ERROR");
expect(RATE_CODES.INVALID_CONFIG).toBe("RATE.INVALID_CONFIG");
expect(RATE_CODES.KEY_MISSING).toBe("RATE.KEY_MISSING");
expect(RATE_CODES.STATE_CORRUPT).toBe("RATE.STATE_CORRUPT");
});
it("attaches context when provided", { timeout: 5000 }, () => {
const err = new RateLimiterError(
RATE_CODES.INVALID_CONFIG,
"rate.invalid.config",
{ context: { key: "user:123", limit: 100 } }
);
expect(err.context).toEqual({ key: "user:123", limit: 100 });
});
it("preserves cause chain", { timeout: 5000 }, () => {
const cause = new Error("underlying db error");
const err = new RateLimiterError(
RATE_CODES.STORAGE_ERROR,
"rate.storage.failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("uses injectable clock for timestamp", { timeout: 5000 }, () => {
const fixedTime = 1_700_000_000_000;
const err = new RateLimiterError(
RATE_CODES.KEY_MISSING,
"rate.key.missing",
{ clock: () => fixedTime }
);
expect(err.timestamp).toBe(fixedTime);
});
it("RATE_CODES is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(RATE_CODES)).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/rate-limiter`.
*
* All codes are namespaced under `RATE` and produced by `defineCodes`,
* yielding deterministic string identifiers that survive tree-shaking
* and minification.
*/
export const RATE_CODES = defineCodes("RATE", [
"STORAGE_ERROR",
"INVALID_CONFIG",
"KEY_MISSING",
"STATE_CORRUPT",
] as const);
export type RateLimiterErrorCode = (typeof RATE_CODES)[keyof typeof RATE_CODES];
/**
* Typed error class for all `@suaveplan/rate-limiter` failures.
*
* Extends `SuaveplanError` so callers can use `instanceof RateLimiterError`
* for narrow discrimination while the full error chain, context, and OTel
* span-event adapter are available through the base class.
*
* @example
* ```ts
* import { RateLimiterError, RATE_CODES } from "@suaveplan/rate-limiter/errors";
*
* throw new RateLimiterError(
* RATE_CODES.STORAGE_ERROR,
* "rate.storage.failed",
* { context: { key: "user:123" }, cause: originalError }
* );
* ```
*/
export class RateLimiterError extends SuaveplanError<RateLimiterErrorCode> {
public constructor(
code: RateLimiterErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "RateLimiterError";
}
}

View File

@@ -0,0 +1,49 @@
// Errors
export { FixedWindow } from "./core/fixed-window/fixed-window.js";
export type { FixedWindowOptions } from "./core/fixed-window/fixed-window.types.js";
// Orchestrator
export { createRateLimiter } from "./core/rate-limiter/rate-limiter.js";
export { SlidingWindow } from "./core/sliding-window/sliding-window.js";
export type { SlidingWindowOptions } from "./core/sliding-window/sliding-window.types.js";
// Core algorithms
export { TokenBucket } from "./core/token-bucket/token-bucket.js";
export type { TokenBucketOptions } from "./core/token-bucket/token-bucket.types.js";
export {
RATE_CODES,
RateLimiterError,
type RateLimiterErrorCode,
} from "./errors/rate-limiter-error.js";
// Middleware helpers
export {
createIpRateLimiter,
createRateLimitChecker,
createRouteRateLimiter,
getRateLimitResult,
} from "./middleware/rate-limit-middleware/rate-limit-middleware.js";
// Storage
export type { RateLimitStorage } from "./storage/adapter/adapter.js";
export type { MemoryStorageOptions } from "./storage/memory-storage/memory-storage.js";
export { MemoryStorage } from "./storage/memory-storage/memory-storage.js";
// Types
export type {
CounterState,
MiddlewareFn,
RateLimitAlgorithm,
RateLimitCondition,
RateLimitConditionOperator,
RateLimitConfig,
RateLimitContext,
RateLimitedPayload,
RateLimiterEvents,
RateLimiterInstance,
RateLimiterMiddlewareOptions,
RateLimiterOptions,
RateLimitResetPayload,
RateLimitResult,
RateLimitRule,
RateLimitState,
SlidingWindowState,
TenantRateLimitConfig,
TokenBucketState,
} from "./types/rate-limiter.types.js";

View File

@@ -0,0 +1,6 @@
export {
createIpRateLimiter,
createRateLimitChecker,
createRouteRateLimiter,
getRateLimitResult,
} from "./rate-limit-middleware.js";

View File

@@ -0,0 +1,99 @@
# rate-limit-middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides framework-agnostic middleware factory helpers that wrap `createRateLimiter` for easy integration into HTTP servers, API gateways, and custom request pipelines. All middleware functions accept `req: unknown` and return a `Promise<RateLimitResult>`, keeping them decoupled from any specific framework (Hono, Express, Fastify, Bun's native server, etc.).
The three exported helpers cover the most common patterns: a simple checker function, IP-based limiting, and route-specific per-path limits. For Hono-specific wiring, consumers can use a thin adapter that reads `result.allowed` and returns a `429` response.
## Features
- `createRateLimitChecker` — returns a `(context) => Promise<RateLimitResult>` wrapper
- `getRateLimitResult` — low-level direct storage+algorithm call
- `createIpRateLimiter` — IP-key extraction with configurable `keyExtractor`, `skip`, `onLimited`
- `createRouteRateLimiter` — per-route config map with route-prefix matching
- All factories accept an optional `skip` predicate to bypass limiting for health probes
- `onLimited` callback for telemetry, logging, or 429-response generation
## Usage: Basic Example
```ts
import { createRateLimitChecker } from "@suaveplan/rate-limiter";
const check = createRateLimitChecker({
defaultConfig: { limit: 100, window: 60_000 },
});
const result = await check({ key: "user:42" });
if (!result.allowed) {
return new Response("Too Many Requests", { status: 429 });
}
```
## Usage: Advanced Example
```ts
import { createIpRateLimiter, createRouteRateLimiter, MemoryStorage } from "@suaveplan/rate-limiter";
const storage = new MemoryStorage({ maxSize: 10_000 });
// IP-based limiter that skips health checks
const ipLimiter = createIpRateLimiter(
{ limit: 200, window: 60_000 },
storage,
{
keyExtractor: (req) => (req as Request).headers.get("x-forwarded-for") ?? "unknown",
skip: (req) => (req as Request).url.endsWith("/health"),
onLimited: (result) => console.warn("IP limited", result),
}
);
// Route-specific limits
const routeLimiter = createRouteRateLimiter(
{
"/api/auth/login": { limit: 5, window: 60_000 },
"/api/search": { limit: 50, window: 10_000 },
},
storage
);
const result = await ipLimiter(request);
if (!result.allowed) {
return new Response("Too Many Requests", { status: 429 });
}
```
## API Reference
### `createRateLimitChecker(options)`
`(options: RateLimiterOptions) => (context: RateLimitContext) => Promise<RateLimitResult>`
### `getRateLimitResult(context, storage, config)`
`(context: RateLimitContext, storage: RateLimitStorage, config: RateLimitConfig) => Promise<RateLimitResult>`
Low-level helper — creates a fresh `SlidingWindow` per call, restoring persisted state from storage.
### `createIpRateLimiter(config, storage?, middlewareOptions?)`
Returns `(req: unknown) => Promise<RateLimitResult>`.
### `createRouteRateLimiter(routes, storage?, middlewareOptions?)`
Returns `(req: unknown) => Promise<RateLimitResult>`. Matches route prefixes in insertion order.
### `RateLimiterMiddlewareOptions`
| Field | Type | Description |
|---|---|---|
| `keyExtractor?` | `(req: unknown) => string` | Extract key from request |
| `onLimited?` | `(result: RateLimitResult) => void` | Called on every check (use to send 429) |
| `skip?` | `(req: unknown) => boolean` | Skip limiting for matching requests |
## Implementation Notes
`createIpRateLimiter` and `createRouteRateLimiter` each construct one `createRateLimiter` instance per route/IP config at factory time, sharing a single storage adapter. Route matching in `createRouteRateLimiter` uses `key.startsWith(route) || key.includes(route)` — for production use with complex routing rules, consumers should provide a custom `keyExtractor` that returns the matched route prefix.

View File

@@ -0,0 +1,194 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryStorage } from "../../storage/memory-storage/memory-storage.js";
import {
createIpRateLimiter,
createRateLimitChecker,
createRouteRateLimiter,
getRateLimitResult,
} from "./rate-limit-middleware.js";
describe("createRateLimitChecker", () => {
it("returns checker that allows requests", { timeout: 5000 }, async () => {
const check = createRateLimitChecker({
defaultConfig: { limit: 10, window: 1000 },
});
const result = await check({ key: "user:1" });
expect(result.allowed).toBe(true);
});
it("rejects when over limit", { timeout: 5000 }, async () => {
const check = createRateLimitChecker({
defaultConfig: { limit: 1, window: 60_000 },
});
await check({ key: "u" });
const result = await check({ key: "u" });
expect(result.allowed).toBe(false);
});
});
describe("getRateLimitResult", () => {
it("allows within limit", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const result = await getRateLimitResult({ key: "k" }, storage, {
limit: 5,
window: 1000,
});
expect(result.allowed).toBe(true);
});
it("rejects over limit", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
await getRateLimitResult({ key: "k" }, storage, {
limit: 1,
window: 60_000,
});
const result = await getRateLimitResult({ key: "k" }, storage, {
limit: 1,
window: 60_000,
});
expect(result.allowed).toBe(false);
});
it("restores state from storage", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
// Pre-seed storage with a near-limit state
const now = Date.now();
await storage.set(
"k2",
{
key: "k2",
algorithm: "sliding-window",
state: { requests: [now - 100, now - 200] },
config: { limit: 3, window: 60_000 },
updatedAt: now,
},
60_000
);
const result = await getRateLimitResult({ key: "k2" }, storage, {
limit: 3,
window: 60_000,
});
// 2 existing + 1 new = 3, which is at limit (not over)
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(0);
});
});
describe("createIpRateLimiter", () => {
it("allows requests below limit", { timeout: 5000 }, async () => {
const limiter = createIpRateLimiter({ limit: 5, window: 1000 });
const result = await limiter("192.168.1.1");
expect(result.allowed).toBe(true);
});
it("uses custom keyExtractor", { timeout: 5000 }, async () => {
const limiter = createIpRateLimiter(
{ limit: 1, window: 60_000 },
undefined,
{ keyExtractor: (req) => `ip-${String(req)}` }
);
await limiter("1.2.3.4");
const result = await limiter("1.2.3.4");
expect(result.allowed).toBe(false);
});
it("skips when skip returns true", { timeout: 5000 }, async () => {
let limited = false;
const limiter = createIpRateLimiter(
{ limit: 0, window: 1000 },
undefined,
{
skip: () => true,
onLimited: () => {
limited = true;
},
}
);
const result = await limiter("anything");
expect(result.allowed).toBe(true);
expect(limited).toBe(false);
});
it("calls onLimited when provided", { timeout: 5000 }, async () => {
let calls = 0;
const limiter = createIpRateLimiter(
{ limit: 1, window: 60_000 },
undefined,
{
onLimited: () => {
calls++;
},
}
);
await limiter("ip");
await limiter("ip"); // rejected
// onLimited is called on every check
expect(calls).toBeGreaterThanOrEqual(1);
});
it("uses shared storage adapter", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const limiter = createIpRateLimiter(
{ limit: 2, window: 60_000 },
storage
);
await limiter("1.1.1.1");
expect(storage.size).toBeGreaterThan(0);
});
});
describe("createRouteRateLimiter", () => {
it("applies route-specific config", { timeout: 5000 }, async () => {
const limiter = createRouteRateLimiter(
{
"/api/login": { limit: 1, window: 60_000 },
},
undefined,
{ keyExtractor: (req) => String(req) }
);
await limiter("/api/login:user1");
const result = await limiter("/api/login:user1");
expect(result.allowed).toBe(false);
});
it("allows unmatched routes", { timeout: 5000 }, async () => {
const limiter = createRouteRateLimiter(
{ "/api/strict": { limit: 0, window: 1000 } },
undefined,
{ keyExtractor: (req) => String(req) }
);
const result = await limiter("/other:key");
expect(result.allowed).toBe(true);
});
it("skip predicate bypasses route limiter", { timeout: 5000 }, async () => {
const limiter = createRouteRateLimiter(
{ "/api": { limit: 0, window: 1000 } },
undefined,
{ skip: () => true }
);
const result = await limiter("/api/anything");
expect(result.allowed).toBe(true);
});
it("uses shared storage adapter", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const limiter = createRouteRateLimiter(
{ "/api": { limit: 5, window: 60_000 } },
storage,
{ keyExtractor: (req) => String(req) }
);
await limiter("/api:user");
expect(storage.size).toBeGreaterThan(0);
});
it("uses default keyExtractor when none provided", {
timeout: 5000,
}, async () => {
const limiter = createRouteRateLimiter({
"/api": { limit: 5, window: 60_000 },
});
const result = await limiter("/api:user");
expect(result.allowed).toBe(true);
});
});

View File

@@ -0,0 +1,151 @@
import { createRateLimiter } from "../../core/rate-limiter/rate-limiter.js";
import { SlidingWindow } from "../../core/sliding-window/sliding-window.js";
import type { RateLimitStorage } from "../../storage/adapter/adapter.js";
import type {
RateLimitConfig,
RateLimitContext,
RateLimiterMiddlewareOptions,
RateLimiterOptions,
RateLimitResult,
} from "../../types/rate-limiter.types.js";
/**
* Create a simple checker function that wraps `createRateLimiter`.
*
* Suitable for cases where you want to call the rate limiter with a
* `RateLimitContext` directly without going through middleware abstraction.
*/
export function createRateLimitChecker(
options: RateLimiterOptions
): (context: RateLimitContext) => Promise<RateLimitResult> {
const limiter = createRateLimiter(options);
return (context: RateLimitContext) => limiter.check(context);
}
/**
* Evaluate a single `RateLimitContext` against a storage adapter and config,
* using a sliding-window algorithm.
*
* Lower-level helper — callers manage the storage lifecycle.
*/
export async function getRateLimitResult(
context: RateLimitContext,
storage: RateLimitStorage,
config: RateLimitConfig
): Promise<RateLimitResult> {
const sw = new SlidingWindow({
limit: config.limit,
windowMs: config.window,
});
const existing = await storage.get(context.key);
if (
existing?.algorithm === "sliding-window" &&
"requests" in existing.state
) {
const cutoff = Date.now() - config.window;
const live = existing.state.requests.filter((ts) => ts >= cutoff);
const internalWindow = (
sw as unknown as { windows: Map<string, number[]> }
).windows;
internalWindow.set(context.key, live);
}
const result = sw.check(context.key);
const internalWindow = (sw as unknown as { windows: Map<string, number[]> })
.windows;
// sw.check() always sets the key; cast as non-null
const requests = internalWindow.get(context.key) as number[];
await storage.set(
context.key,
{
key: context.key,
algorithm: "sliding-window",
state: { requests: [...requests] },
config,
updatedAt: Date.now(),
},
config.window
);
return result;
}
/**
* Create a framework-agnostic IP-based rate limiter middleware factory.
*
* The returned function extracts an IP string from `req` using `keyExtractor`
* (or `(req) => String(req)` fallback), then runs the rate-limit check.
* The caller is responsible for reading the result and responding.
*/
export function createIpRateLimiter(
config: RateLimitConfig,
storage?: RateLimitStorage,
middlewareOptions?: RateLimiterMiddlewareOptions
): (req: unknown) => Promise<RateLimitResult> {
const limiter = createRateLimiter({
defaultConfig: config,
...(storage !== undefined ? { storage } : {}),
});
const keyExtractor =
middlewareOptions?.keyExtractor ?? ((req: unknown) => String(req));
const skip = middlewareOptions?.skip;
return async (req: unknown): Promise<RateLimitResult> => {
if (skip?.(req)) {
return {
allowed: true,
remaining: config.limit,
limit: config.limit,
resetIn: 0,
};
}
const key = keyExtractor(req);
const result = await limiter.check({ key });
middlewareOptions?.onLimited?.(result);
return result;
};
}
/**
* Create a framework-agnostic route-specific rate limiter middleware factory.
*
* Each key in `routes` maps a route pattern string to a `RateLimitConfig`.
* The `keyExtractor` receives `req` and returns a `"{route}:{identifier}"`
* string used as the rate-limit key. Unmatched routes fall through with
* `allowed: true`.
*/
export function createRouteRateLimiter(
routes: Record<string, RateLimitConfig>,
storage?: RateLimitStorage,
middlewareOptions?: RateLimiterMiddlewareOptions
): (req: unknown) => Promise<RateLimitResult> {
const limiters = new Map<string, ReturnType<typeof createRateLimiter>>();
for (const [route, config] of Object.entries(routes)) {
limiters.set(
route,
createRateLimiter({
defaultConfig: config,
keyPrefix: `route:${route}`,
...(storage !== undefined ? { storage } : {}),
})
);
}
const keyExtractor =
middlewareOptions?.keyExtractor ?? ((req: unknown) => String(req));
const skip = middlewareOptions?.skip;
return async (req: unknown): Promise<RateLimitResult> => {
if (skip?.(req)) {
return { allowed: true, remaining: 0, limit: 0, resetIn: 0 };
}
const key = keyExtractor(req);
// Check all matching routes
for (const [route, limiter] of limiters) {
if (key.startsWith(route) || key.includes(route)) {
const result = await limiter.check({ key });
middlewareOptions?.onLimited?.(result);
return result;
}
}
return { allowed: true, remaining: 0, limit: 0, resetIn: 0 };
};
}

View File

@@ -0,0 +1,4 @@
export type {
MiddlewareFn,
RateLimiterMiddlewareOptions,
} from "../../types/rate-limiter.types.js";

View File

@@ -0,0 +1,64 @@
# rate-limiter.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod v4 validation schemas for all configuration objects in `@suaveplan/rate-limiter`. These schemas enable runtime validation of user-supplied configuration, safe parsing of stored state from external sources, and documentation of valid value ranges. They are tree-shakeable — import only the schemas you need.
Non-serialisable fields (`clock`, `bus`, `logger`, `storage`) are intentionally omitted from `RateLimiterOptionsSchema` since they cannot be represented in Zod.
## Features
- `RateLimitConfigSchema` — validates `limit`, `window`, optional `id` and `description`
- `RateLimitConditionSchema` — validates five-operator condition objects
- `RateLimitRuleSchema` — extends config with `priority`, `conditions`, `bypass`
- `RateLimitContextSchema` — validates per-request context objects
- `RateLimiterOptionsSchema` — partial options schema for serialisable fields
- `TokenBucketOptionsSchema`, `SlidingWindowOptionsSchema`, `FixedWindowOptionsSchema` — algorithm options
- `MemoryStorageOptionsSchema` — storage options
## Usage: Basic Example
```ts
import { RateLimitConfigSchema } from "@suaveplan/rate-limiter/schemas";
const result = RateLimitConfigSchema.safeParse({ limit: 100, window: 60_000 });
if (!result.success) {
console.error(result.error.issues);
}
```
## Usage: Advanced Example
```ts
import {
RateLimiterOptionsSchema,
RateLimitRuleSchema,
} from "@suaveplan/rate-limiter/schemas";
const raw = JSON.parse(configJson);
const opts = RateLimiterOptionsSchema.parse(raw);
// Validate individual rules from an API payload:
const rules = z.array(RateLimitRuleSchema).parse(payload.rules);
```
## API Reference
| Schema | Validates |
|---|---|
| `RateLimitConfigSchema` | `{ limit, window, id?, description? }` |
| `RateLimitConditionSchema` | `{ field, operator, value }` |
| `RateLimitRuleSchema` | `RateLimitConfig + { priority?, conditions?, bypass? }` |
| `RateLimitContextSchema` | `{ key, tenantId?, userId?, ip?, path?, method?, metadata? }` |
| `RateLimiterOptionsSchema` | Serialisable subset of `RateLimiterOptions` |
| `TokenBucketOptionsSchema` | `{ capacity, refillRate, initialTokens? }` |
| `SlidingWindowOptionsSchema` | `{ limit, windowMs }` |
| `FixedWindowOptionsSchema` | `{ limit, windowMs }` |
| `MemoryStorageOptionsSchema` | `{ maxSize? }` |
## Implementation Notes
`z.record(z.string(), z.unknown())` is used for `metadata` and `tenantMultipliers` fields, which requires both key and value type arguments in Zod v4. All integer fields use `.int()` to prevent floating-point surprises in window calculations.

View File

@@ -0,0 +1,219 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FixedWindowOptionsSchema,
MemoryStorageOptionsSchema,
RateLimitConditionSchema,
RateLimitConfigSchema,
RateLimitContextSchema,
RateLimiterOptionsSchema,
RateLimitRuleSchema,
SlidingWindowOptionsSchema,
TokenBucketOptionsSchema,
} from "./rate-limiter.schemas.js";
describe("RateLimitConfigSchema", () => {
it("validates valid config", { timeout: 5000 }, () => {
expect(
RateLimitConfigSchema.safeParse({ limit: 100, window: 60_000 })
.success
).toBe(true);
});
it("rejects missing limit", { timeout: 5000 }, () => {
expect(RateLimitConfigSchema.safeParse({ window: 1000 }).success).toBe(
false
);
});
it("rejects non-integer limit", { timeout: 5000 }, () => {
expect(
RateLimitConfigSchema.safeParse({ limit: 1.5, window: 1000 })
.success
).toBe(false);
});
it("accepts optional id and description", { timeout: 5000 }, () => {
const r = RateLimitConfigSchema.safeParse({
limit: 10,
window: 1000,
id: "my-rule",
description: "test",
});
expect(r.success).toBe(true);
});
});
describe("RateLimitConditionSchema", () => {
it("validates eq condition", { timeout: 5000 }, () => {
expect(
RateLimitConditionSchema.safeParse({
field: "userId",
operator: "eq",
value: "admin",
}).success
).toBe(true);
});
it("rejects unknown operator", { timeout: 5000 }, () => {
expect(
RateLimitConditionSchema.safeParse({
field: "x",
operator: "gt",
value: 1,
}).success
).toBe(false);
});
it("accepts all operators", { timeout: 5000 }, () => {
for (const op of ["eq", "neq", "in", "nin", "matches"]) {
expect(
RateLimitConditionSchema.safeParse({
field: "f",
operator: op,
value: "v",
}).success
).toBe(true);
}
});
});
describe("RateLimitRuleSchema", () => {
it("validates rule with bypass", { timeout: 5000 }, () => {
const r = RateLimitRuleSchema.safeParse({
limit: 0,
window: 1000,
bypass: true,
priority: 100,
});
expect(r.success).toBe(true);
});
it("validates rule with conditions", { timeout: 5000 }, () => {
const r = RateLimitRuleSchema.safeParse({
limit: 5,
window: 1000,
conditions: [{ field: "ip", operator: "eq", value: "1.2.3.4" }],
});
expect(r.success).toBe(true);
});
});
describe("RateLimitContextSchema", () => {
it("validates minimal context", { timeout: 5000 }, () => {
expect(
RateLimitContextSchema.safeParse({ key: "user:1" }).success
).toBe(true);
});
it("rejects empty key", { timeout: 5000 }, () => {
expect(RateLimitContextSchema.safeParse({ key: "" }).success).toBe(
false
);
});
it("validates full context", { timeout: 5000 }, () => {
expect(
RateLimitContextSchema.safeParse({
key: "k",
tenantId: "acme",
userId: "u1",
ip: "1.1.1.1",
path: "/api",
method: "GET",
metadata: { custom: 1 },
}).success
).toBe(true);
});
});
describe("RateLimiterOptionsSchema", () => {
it("validates minimal options", { timeout: 5000 }, () => {
expect(
RateLimiterOptionsSchema.safeParse({
defaultConfig: { limit: 10, window: 1000 },
}).success
).toBe(true);
});
it("validates full options", { timeout: 5000 }, () => {
expect(
RateLimiterOptionsSchema.safeParse({
defaultConfig: { limit: 100, window: 60_000 },
rules: [],
enableTenantLimits: true,
tenantMultipliers: { enterprise: 5 },
keyPrefix: "rl",
}).success
).toBe(true);
});
});
describe("TokenBucketOptionsSchema", () => {
it("validates valid options", { timeout: 5000 }, () => {
expect(
TokenBucketOptionsSchema.safeParse({
capacity: 100,
refillRate: 0.1,
}).success
).toBe(true);
});
it("accepts initialTokens", { timeout: 5000 }, () => {
expect(
TokenBucketOptionsSchema.safeParse({
capacity: 100,
refillRate: 0.1,
initialTokens: 50,
}).success
).toBe(true);
});
it("rejects negative capacity", { timeout: 5000 }, () => {
expect(
TokenBucketOptionsSchema.safeParse({
capacity: -1,
refillRate: 0.1,
}).success
).toBe(false);
});
});
describe("SlidingWindowOptionsSchema", () => {
it("validates valid options", { timeout: 5000 }, () => {
expect(
SlidingWindowOptionsSchema.safeParse({
limit: 100,
windowMs: 60_000,
}).success
).toBe(true);
});
});
describe("FixedWindowOptionsSchema", () => {
it("validates valid options", { timeout: 5000 }, () => {
expect(
FixedWindowOptionsSchema.safeParse({
limit: 100,
windowMs: 60_000,
}).success
).toBe(true);
});
});
describe("MemoryStorageOptionsSchema", () => {
it("validates empty options", { timeout: 5000 }, () => {
expect(MemoryStorageOptionsSchema.safeParse({}).success).toBe(true);
});
it("validates maxSize", { timeout: 5000 }, () => {
expect(
MemoryStorageOptionsSchema.safeParse({ maxSize: 5000 }).success
).toBe(true);
});
it("rejects non-positive maxSize", { timeout: 5000 }, () => {
expect(
MemoryStorageOptionsSchema.safeParse({ maxSize: 0 }).success
).toBe(false);
});
});

View File

@@ -0,0 +1,69 @@
import z from "zod";
/** Zod schema for `RateLimitConfig`. */
export const RateLimitConfigSchema = z.object({
limit: z.number().int().positive(),
window: z.number().int().positive(),
id: z.string().optional(),
description: z.string().optional(),
});
/** Zod schema for `RateLimitCondition`. */
export const RateLimitConditionSchema = z.object({
field: z.string().min(1),
operator: z.enum(["eq", "neq", "in", "nin", "matches"]),
value: z.unknown(),
});
/** Zod schema for `RateLimitRule`. */
// Rules allow `limit: 0` for bypass rules, so override the base positive() constraint.
export const RateLimitRuleSchema = RateLimitConfigSchema.extend({
limit: z.number().int().nonnegative(),
priority: z.number().optional(),
conditions: z.array(RateLimitConditionSchema).optional(),
bypass: z.boolean().optional(),
});
/** Zod schema for `RateLimitContext`. */
export const RateLimitContextSchema = z.object({
key: z.string().min(1),
tenantId: z.string().optional(),
userId: z.string().optional(),
ip: z.string().optional(),
path: z.string().optional(),
method: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Zod schema for `RateLimiterOptions` (partial — omits non-serialisable fields). */
export const RateLimiterOptionsSchema = z.object({
defaultConfig: RateLimitConfigSchema,
rules: z.array(RateLimitRuleSchema).optional(),
enableTenantLimits: z.boolean().optional(),
tenantMultipliers: z.record(z.string(), z.number()).optional(),
keyPrefix: z.string().optional(),
});
/** Zod schema for `TokenBucketOptions`. */
export const TokenBucketOptionsSchema = z.object({
capacity: z.number().positive(),
refillRate: z.number().positive(),
initialTokens: z.number().nonnegative().optional(),
});
/** Zod schema for `SlidingWindowOptions`. */
export const SlidingWindowOptionsSchema = z.object({
limit: z.number().int().positive(),
windowMs: z.number().int().positive(),
});
/** Zod schema for `FixedWindowOptions`. */
export const FixedWindowOptionsSchema = z.object({
limit: z.number().int().positive(),
windowMs: z.number().int().positive(),
});
/** Zod schema for `MemoryStorageOptions`. */
export const MemoryStorageOptionsSchema = z.object({
maxSize: z.number().int().positive().optional(),
});

View File

@@ -0,0 +1,65 @@
# storage-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `RateLimitStorage` interface — the pluggable persistence contract that decouples rate-limit state from any specific backend. All algorithm implementations and the `RateLimiterInstance` orchestrator depend only on this interface, making it possible to swap in Redis, SurrealDB, Postgres, or any key-value store without modifying core logic.
The interface is intentionally minimal: four async methods covering read, write, delete, and clear. The `ttl` parameter on `set` is advisory: conformant backends SHOULD honour it, but the algorithms are defensive against stale state.
## Features
- `get(key)` — returns `RateLimitState | null` (null means absent or expired)
- `set(key, state, ttl)` — persist with advisory TTL in milliseconds
- `delete(key)` — idempotent removal
- `clear()` — full eviction (tests and admin reset)
- Zero runtime code — pure interface definition
## Usage: Basic Example
```ts
import type { RateLimitStorage } from "@suaveplan/rate-limiter";
class RedisStorage implements RateLimitStorage {
async get(key: string) { /* ... */ return null; }
async set(key: string, state, ttl: number) { /* ... */ }
async delete(key: string) { /* ... */ }
async clear() { /* ... */ }
}
```
## Usage: Advanced Example
```ts
import { createRateLimiter } from "@suaveplan/rate-limiter";
import type { RateLimitStorage } from "@suaveplan/rate-limiter";
// Provide a Redis-backed storage for distributed rate limiting:
const storage: RateLimitStorage = new RedisStorage(redisClient);
const limiter = createRateLimiter({
defaultConfig: { limit: 1000, window: 60_000 },
storage,
});
const result = await limiter.check({ key: "tenant:acme:user:7" });
```
## API Reference
### `RateLimitStorage`
Interface with four methods:
| Method | Signature | Description |
|---|---|---|
| `get` | `(key: string) => Promise<RateLimitState \| null>` | Read state or null |
| `set` | `(key: string, state: RateLimitState, ttl: number) => Promise<void>` | Write with TTL |
| `delete` | `(key: string) => Promise<void>` | Remove key |
| `clear` | `() => Promise<void>` | Remove all entries |
## Implementation Notes
The `ttl` passed to `set` is derived by the caller from the remaining window duration. Backends that do not support native TTL (e.g. in-process Maps) should store the expiry timestamp alongside the state and filter it in `get`. The built-in `MemoryStorage` follows this pattern.

View File

@@ -0,0 +1,31 @@
import type { RateLimitState } from "../../types/rate-limiter.types.js";
/**
* Pluggable persistence contract for rate-limit state.
*
* Any backend — in-memory, Redis, SurrealDB, or RDBMS — can satisfy this
* interface to provide distributed rate limiting without changing the core
* algorithm implementations.
*/
export interface RateLimitStorage {
/**
* Retrieve the current state for `key`, or `null` if absent or expired.
*/
get(key: string): Promise<RateLimitState | null>;
/**
* Persist `state` under `key` with a time-to-live of `ttl` milliseconds.
* The backend SHOULD evict the entry after `ttl` ms.
*/
set(key: string, state: RateLimitState, ttl: number): Promise<void>;
/**
* Remove the entry for `key`. Resolves without error if the key is absent.
*/
delete(key: string): Promise<void>;
/**
* Remove all entries. Used primarily in tests and admin reset operations.
*/
clear(): Promise<void>;
}

View File

@@ -0,0 +1 @@
export type { RateLimitStorage } from "./adapter.js";

View File

@@ -0,0 +1 @@
export type { RateLimitStorage } from "./adapter.js";

View File

@@ -0,0 +1 @@
export { MemoryStorage, type MemoryStorageOptions } from "./memory-storage.js";

View File

@@ -0,0 +1,78 @@
# memory-storage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryStorage` is the built-in, zero-dependency `RateLimitStorage` adapter for `@suaveplan/rate-limiter`. It stores rate-limit state in a `Map` within the current process, using insertion-order iteration for LRU eviction and lazy TTL expiry checks on `get`. It is ideal for single-process servers, testing environments, and local development where distributed coordination is not required.
The injectable `Clock` enables fully deterministic unit tests by advancing time without sleeping.
## Features
- LRU eviction: when `maxSize` is exceeded the oldest key is removed
- Lazy TTL enforcement: expired entries are evicted on next `get` access
- Injectable `Clock` for deterministic time in tests
- `size` getter for observability
- Implements full `RateLimitStorage` interface (`get`, `set`, `delete`, `clear`)
## Usage: Basic Example
```ts
import { MemoryStorage } from "@suaveplan/rate-limiter";
const storage = new MemoryStorage({ maxSize: 5000 });
await storage.set("user:1", state, 60_000);
const loaded = await storage.get("user:1");
```
## Usage: Advanced Example
```ts
import { MemoryStorage } from "@suaveplan/rate-limiter";
import { createRateLimiter } from "@suaveplan/rate-limiter";
// Use a controlled clock for deterministic TTL tests:
let t = 1_700_000_000_000;
const clock = () => t;
const storage = new MemoryStorage({ maxSize: 100, clock });
const limiter = createRateLimiter({
defaultConfig: { limit: 10, window: 1000 },
storage,
clock,
});
// Advance time past TTL to verify state expiry:
await limiter.check({ key: "user:1" });
t += 2000; // advance past window
const state = await storage.get("rl:user:1");
// state is null — TTL expired
```
## API Reference
### `MemoryStorage`
```ts
class MemoryStorage implements RateLimitStorage {
constructor(options?: MemoryStorageOptions)
get(key: string): Promise<RateLimitState | null>
set(key: string, state: RateLimitState, ttl: number): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
readonly size: number
}
```
### `MemoryStorageOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `maxSize` | `number` | `10000` | Maximum keys before LRU eviction |
| `clock` | `Clock` | `Date.now` | Injectable time source |
## Implementation Notes
LRU is implemented via `Map` deletion-then-reinsertion on both `get` and `set`, exploiting the specification guarantee that `Map` iterates in insertion order. The `set` path evicts exactly one entry when the map exceeds `maxSize`, keeping the operation O(1). Expired entries are not proactively swept — they accumulate until accessed, which keeps the hot path simple but means `size` may temporarily include expired keys.

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { RateLimitState } from "../../types/rate-limiter.types.js";
import { MemoryStorage } from "./memory-storage.js";
function makeState(key: string): RateLimitState {
return {
key,
algorithm: "fixed-window",
state: { count: 1, windowStart: 1000 },
config: { limit: 10, window: 60_000 },
updatedAt: 1000,
};
}
describe("MemoryStorage", () => {
let clock: () => number;
let t: number;
let storage: MemoryStorage;
beforeEach(() => {
t = 1_700_000_000_000;
clock = () => t;
storage = new MemoryStorage({ clock });
});
it("returns null for unknown key", { timeout: 5000 }, async () => {
expect(await storage.get("missing")).toBeNull();
});
it("round-trips a state", { timeout: 5000 }, async () => {
const state = makeState("user:1");
await storage.set("user:1", state, 60_000);
const loaded = await storage.get("user:1");
expect(loaded).toEqual(state);
});
it("returns null after TTL expires", { timeout: 5000 }, async () => {
const state = makeState("user:2");
await storage.set("user:2", state, 1000);
t += 1001;
expect(await storage.get("user:2")).toBeNull();
});
it("returns entry within TTL", { timeout: 5000 }, async () => {
const state = makeState("user:3");
await storage.set("user:3", state, 1000);
t += 500;
expect(await storage.get("user:3")).toEqual(state);
});
it("delete removes entry", { timeout: 5000 }, async () => {
const state = makeState("user:4");
await storage.set("user:4", state, 60_000);
await storage.delete("user:4");
expect(await storage.get("user:4")).toBeNull();
});
it("delete on missing key is idempotent", { timeout: 5000 }, async () => {
await expect(storage.delete("nonexistent")).resolves.toBeUndefined();
});
it("clear removes all entries", { timeout: 5000 }, async () => {
await storage.set("a", makeState("a"), 60_000);
await storage.set("b", makeState("b"), 60_000);
await storage.clear();
expect(storage.size).toBe(0);
expect(await storage.get("a")).toBeNull();
});
it("LRU eviction removes oldest when maxSize exceeded", {
timeout: 5000,
}, async () => {
const s = new MemoryStorage({ maxSize: 3, clock });
await s.set("k1", makeState("k1"), 60_000);
await s.set("k2", makeState("k2"), 60_000);
await s.set("k3", makeState("k3"), 60_000);
expect(s.size).toBe(3);
await s.set("k4", makeState("k4"), 60_000);
expect(s.size).toBe(3);
// k1 should have been evicted as the oldest
expect(await s.get("k1")).toBeNull();
expect(await s.get("k4")).not.toBeNull();
});
it("get promotes accessed entry (LRU refresh)", {
timeout: 5000,
}, async () => {
const s = new MemoryStorage({ maxSize: 2, clock });
await s.set("k1", makeState("k1"), 60_000);
await s.set("k2", makeState("k2"), 60_000);
// Access k1 to make it most-recently-used
await s.get("k1");
// Adding k3 should evict k2 (oldest after k1 was moved)
await s.set("k3", makeState("k3"), 60_000);
expect(await s.get("k1")).not.toBeNull();
expect(await s.get("k2")).toBeNull();
expect(await s.get("k3")).not.toBeNull();
});
it("overwrite updates state and TTL", { timeout: 5000 }, async () => {
const state1 = makeState("x");
const state2 = { ...makeState("x"), updatedAt: 9999 };
await storage.set("x", state1, 60_000);
await storage.set("x", state2, 60_000);
const loaded = await storage.get("x");
expect(loaded?.updatedAt).toBe(9999);
});
it("size reflects current entries", { timeout: 5000 }, async () => {
expect(storage.size).toBe(0);
await storage.set("a", makeState("a"), 60_000);
expect(storage.size).toBe(1);
});
});

View File

@@ -0,0 +1,79 @@
import type { Clock } from "@suaveplan/types";
import { DEFAULT_MEMORY_MAX_SIZE } from "../../constants/index.js";
import type { RateLimitState } from "../../types/rate-limiter.types.js";
import type { RateLimitStorage } from "../adapter/adapter.js";
/** Options for constructing a `MemoryStorage` instance. */
export interface MemoryStorageOptions {
/** Maximum number of keys before LRU eviction begins. Defaults to 10 000. */
maxSize?: number;
/** Injectable clock for TTL calculations. Defaults to `Date.now`. */
clock?: Clock;
}
interface StorageEntry {
state: RateLimitState;
expiresAt: number;
}
/**
* In-process LRU storage adapter for `@suaveplan/rate-limiter`.
*
* Suitable for single-process deployments, testing, and development. Uses a
* `Map` (insertion-order iteration) to implement LRU eviction: on each `set`
* the key is moved to the end, and when `maxSize` is exceeded the oldest
* entry is deleted. TTL is enforced lazily in `get`.
*/
export class MemoryStorage implements RateLimitStorage {
protected readonly entries: Map<string, StorageEntry>;
protected readonly maxSize: number;
protected readonly clock: Clock;
public constructor(options?: MemoryStorageOptions) {
this.maxSize = options?.maxSize ?? DEFAULT_MEMORY_MAX_SIZE;
this.clock = options?.clock ?? (() => Date.now());
this.entries = new Map();
}
public async get(key: string): Promise<RateLimitState | null> {
const entry = this.entries.get(key);
if (entry === undefined) return null;
if (this.clock() > entry.expiresAt) {
this.entries.delete(key);
return null;
}
// LRU: move to end
this.entries.delete(key);
this.entries.set(key, entry);
return entry.state;
}
public async set(
key: string,
state: RateLimitState,
ttl: number
): Promise<void> {
const expiresAt = this.clock() + ttl;
// LRU: remove existing so insertion puts it at the end
this.entries.delete(key);
this.entries.set(key, { state, expiresAt });
if (this.entries.size > this.maxSize) {
// Evict the oldest (first) entry — always defined when size > 0
const oldest = this.entries.keys().next().value as string;
this.entries.delete(oldest);
}
}
public async delete(key: string): Promise<void> {
this.entries.delete(key);
}
public async clear(): Promise<void> {
this.entries.clear();
}
/** Current number of live (non-expired) entries. */
public get size(): number {
return this.entries.size;
}
}

View File

@@ -0,0 +1,88 @@
# rate-limiter.types
## Purpose
Defines all shared TypeScript interfaces and type aliases used across the `@suaveplan/rate-limiter` package. Centralising types here avoids circular import cycles between implementation modules and ensures that every consumer — storage adapters, algorithm classes, middleware helpers, and Zod schemas — shares a single source of truth for type definitions.
The types are deliberately kept as pure interfaces with no runtime footprint. Every field that is optional uses the `readonly` modifier so accidental mutation at the call site is caught by the TypeScript compiler.
## Features
- `RateLimitResult` — the universal check result (allowed, remaining, resetIn, retryAfter)
- `RateLimitConfig` and `RateLimitRule` — static configuration shapes for algorithms and rule routing
- `RateLimitCondition` — field-level predicate for rule matching with five operators
- `RateLimitContext` — per-request context used for rule evaluation and key construction
- `RateLimiterOptions` — top-level orchestrator configuration
- Serialisable state interfaces: `TokenBucketState`, `SlidingWindowState`, `CounterState`, `RateLimitState`
- Event payload types: `RateLimitedPayload`, `RateLimitResetPayload`, `RateLimiterEvents`
- `RateLimiterInstance` — the returned facade from `createRateLimiter`
- `MiddlewareFn` and `RateLimiterMiddlewareOptions` — framework-agnostic middleware contracts
## Usage: Basic Example
```ts
import type { RateLimitResult, RateLimitContext } from "@suaveplan/rate-limiter";
const context: RateLimitContext = {
key: "user:42",
tenantId: "acme",
ip: "1.2.3.4",
};
const result: RateLimitResult = await rateLimiter.check(context);
if (!result.allowed) {
console.log(`Retry after ${result.retryAfter}ms`);
}
```
## Usage: Advanced Example
```ts
import type {
RateLimitRule,
RateLimitCondition,
RateLimiterOptions,
} from "@suaveplan/rate-limiter";
const adminBypassRule: RateLimitRule = {
limit: 0,
window: 60_000,
priority: 100,
bypass: true,
conditions: [
{ field: "userId", operator: "in", value: ["admin-1", "admin-2"] },
],
};
const opts: RateLimiterOptions = {
defaultConfig: { limit: 100, window: 60_000 },
rules: [adminBypassRule],
enableTenantLimits: true,
tenantMultipliers: { enterprise: 10 },
};
```
## API Reference
### `RateLimitResult`
| Field | Type | Description |
|---|---|---|
| `allowed` | `boolean` | Whether the request should proceed |
| `remaining` | `number` | Remaining capacity in the current window |
| `limit` | `number` | Effective limit applied |
| `resetIn` | `number` | Milliseconds until window/bucket resets |
| `retryAfter?` | `number` | Milliseconds to wait (only when `allowed === false`) |
| `metadata?` | `Record<string, unknown>` | Optional algorithm-specific metadata |
### `RateLimitConditionOperator`
`"eq" | "neq" | "in" | "nin" | "matches"` — five operators for field-based predicate evaluation.
### `RateLimitAlgorithm`
`"token-bucket" | "sliding-window" | "fixed-window"` — the three built-in algorithms.
## Implementation Notes
The `RateLimiterEvents` interface is intentionally typed with string literal keys (`"rate:limit:exceeded"`) so it can be passed directly to `EventBusLike<RateLimiterEvents>`. The storage adapter interface (`RateLimitStorage`) is defined in the `storage-adapter` module to avoid a circular dependency back into this types file.

View File

@@ -0,0 +1,148 @@
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { RateLimitStorage } from "../storage/adapter/adapter.js";
/** Result returned by every rate-limit check. */
export interface RateLimitResult {
readonly allowed: boolean;
readonly remaining: number;
readonly limit: number;
/** Milliseconds until the current window or bucket resets. */
readonly resetIn: number;
/** Milliseconds to wait before retrying. Only present when `allowed === false`. */
readonly retryAfter?: number;
readonly metadata?: Record<string, unknown>;
}
/** Static configuration shared by algorithms and rules. */
export interface RateLimitConfig {
readonly limit: number;
/** Window duration in milliseconds. */
readonly window: number;
readonly id?: string;
readonly description?: string;
}
/** Condition operators for rule-based routing. */
export type RateLimitConditionOperator =
| "eq"
| "neq"
| "in"
| "nin"
| "matches";
/** A single predicate evaluated against a `RateLimitContext` field. */
export interface RateLimitCondition {
readonly field: string;
readonly operator: RateLimitConditionOperator;
readonly value: unknown;
}
/** A named rule that extends a config with priority and conditions. */
export interface RateLimitRule extends RateLimitConfig {
readonly priority?: number;
readonly conditions?: RateLimitCondition[];
/** When `true`, matching requests bypass limiting entirely. */
readonly bypass?: boolean;
}
/** Contextual data about the incoming request used for rule matching. */
export interface RateLimitContext {
readonly key: string;
readonly tenantId?: string;
readonly userId?: string;
readonly ip?: string;
readonly path?: string;
readonly method?: string;
readonly metadata?: Record<string, unknown>;
}
/** Top-level options for `createRateLimiter`. */
export interface RateLimiterOptions {
readonly defaultConfig: RateLimitConfig;
readonly storage?: RateLimitStorage;
readonly rules?: RateLimitRule[];
readonly enableTenantLimits?: boolean;
readonly tenantMultipliers?: Record<string, number>;
readonly keyPrefix?: string;
readonly clock?: Clock;
readonly bus?: EventBusLike;
readonly logger?: LoggerLike;
}
/** Serialisable token-bucket state for persistence. */
export interface TokenBucketState {
tokens: number;
lastRefill: number;
}
/** Serialisable sliding-window state for persistence. */
export interface SlidingWindowState {
requests: number[];
}
/** Serialisable fixed-window counter state for persistence. */
export interface CounterState {
count: number;
windowStart: number;
}
/** Supported rate-limit algorithms. */
export type RateLimitAlgorithm =
| "token-bucket"
| "sliding-window"
| "fixed-window";
/** Full serialisable state for a single rate-limited key. */
export interface RateLimitState {
key: string;
algorithm: RateLimitAlgorithm;
state: TokenBucketState | SlidingWindowState | CounterState;
config: RateLimitConfig;
updatedAt: number;
}
/** Tenant-specific config variant. */
export interface TenantRateLimitConfig extends RateLimitConfig {
tenantId: string;
multiplier?: number;
}
/** Payload emitted on `rate:limit:exceeded`. */
export interface RateLimitedPayload {
key: string;
tenantId?: string;
remaining: number;
resetIn: number;
}
/** Payload emitted on `rate:limit:reset`. */
export interface RateLimitResetPayload {
key: string;
tenantId?: string;
}
/** Typed event map for the rate-limiter event bus. */
export interface RateLimiterEvents {
"rate:limit:exceeded": RateLimitedPayload;
"rate:limit:reset": RateLimitResetPayload;
}
/** The instance returned by `createRateLimiter`. */
export interface RateLimiterInstance {
check(context: RateLimitContext): Promise<RateLimitResult>;
reset(key: string): Promise<void>;
getState(key: string): Promise<RateLimitState | null>;
}
/** Middleware function type used by framework adapters. */
export type MiddlewareFn = (
req: unknown,
...args: unknown[]
) => Promise<RateLimitResult>;
/** Options for middleware factory helpers. */
export interface RateLimiterMiddlewareOptions {
keyExtractor?: (req: unknown) => string;
onLimited?: (result: RateLimitResult) => void;
skip?: (req: unknown) => boolean;
}

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { FixedWindow } from "../src/core/fixed-window/fixed-window.js";
import { createRateLimiter } from "../src/core/rate-limiter/rate-limiter.js";
import { SlidingWindow } from "../src/core/sliding-window/sliding-window.js";
import { TokenBucket } from "../src/core/token-bucket/token-bucket.js";
import { MemoryStorage } from "../src/storage/memory-storage/memory-storage.js";
describe("Integration: multi-algorithm coordination", () => {
let t: number;
let clock: () => number;
beforeEach(() => {
t = 1_000_000;
clock = () => t;
});
it("token bucket + storage round-trip", { timeout: 5000 }, () => {
const bucket = new TokenBucket({
capacity: 10,
refillRate: 0.01,
clock,
});
// Drain bucket
for (let i = 0; i < 10; i++) {
expect(bucket.consume().allowed).toBe(true);
}
expect(bucket.consume().allowed).toBe(false);
// Refill via time
t += 1000; // +10 tokens
expect(bucket.consume().allowed).toBe(true);
});
it("sliding window per-key isolation", { timeout: 5000 }, async () => {
const storage = new MemoryStorage({ clock });
const limiter = createRateLimiter({
defaultConfig: { limit: 2, window: 60_000 },
storage,
clock,
keyPrefix: "rl",
});
// Exhaust key "a:user:1"
await limiter.check({ key: "a:user:1", tenantId: "a" });
await limiter.check({ key: "a:user:1", tenantId: "a" });
const r1 = await limiter.check({ key: "a:user:1", tenantId: "a" });
expect(r1.allowed).toBe(false);
// "b:user:1" is a separate storage key and is unaffected
const r2 = await limiter.check({ key: "b:user:1", tenantId: "b" });
expect(r2.allowed).toBe(true);
});
it("fixed window and sliding window agreement on boundary", {
timeout: 5000,
}, () => {
t = 0;
const fw = new FixedWindow({ limit: 3, windowMs: 1000, clock });
const sw = new SlidingWindow({ limit: 3, windowMs: 1000, clock });
for (let i = 0; i < 3; i++) {
expect(fw.check("k").allowed).toBe(true);
expect(sw.check("k").allowed).toBe(true);
}
// Both reject
expect(fw.check("k").allowed).toBe(false);
expect(sw.check("k").allowed).toBe(false);
// Advance past window
t = 1001;
expect(fw.check("k").allowed).toBe(true);
expect(sw.check("k").allowed).toBe(true);
});
it("rate limiter with all rule conditions", { timeout: 5000 }, async () => {
const limiter = createRateLimiter({
defaultConfig: { limit: 100, window: 1000 },
rules: [
{
id: "ip-block",
limit: 0,
window: 1000,
priority: 90,
bypass: false,
conditions: [
{ field: "ip", operator: "eq", value: "10.0.0.1" },
],
},
{
id: "admin-pass",
limit: 0,
window: 1000,
priority: 100,
bypass: true,
conditions: [
{ field: "userId", operator: "in", value: ["admin"] },
],
},
],
clock,
});
// Admin bypasses all limits
const adminResult = await limiter.check({
key: "admin-key",
userId: "admin",
});
expect(adminResult.allowed).toBe(true);
// Blocked IP hits limit immediately
const blockedResult = await limiter.check({
key: "blocked-ip",
ip: "10.0.0.1",
});
expect(blockedResult.allowed).toBe(false);
});
it("getState after multiple checks is consistent", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage({ clock });
const limiter = createRateLimiter({
defaultConfig: { limit: 5, window: 1000 },
storage,
clock,
});
await limiter.check({ key: "u1" });
await limiter.check({ key: "u1" });
await limiter.check({ key: "u1" });
const state = await limiter.getState("u1");
expect(state).not.toBeNull();
expect(state?.algorithm).toBe("sliding-window");
expect("requests" in state!.state).toBe(true);
if ("requests" in state!.state) {
expect(state!.state.requests.length).toBe(3);
}
});
});

View File

@@ -0,0 +1,281 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createRateLimiter } from "../src/core/rate-limiter/rate-limiter.js";
import { SlidingWindow } from "../src/core/sliding-window/sliding-window.js";
import { TokenBucket } from "../src/core/token-bucket/token-bucket.js";
import { MemoryStorage } from "../src/storage/memory-storage/memory-storage.js";
import type { RateLimitState } from "../src/types/rate-limiter.types.js";
describe("Property: TokenBucket invariants", () => {
it("available never exceeds capacity", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 1000 }),
fc.float({ min: Math.fround(0.001), max: Math.fround(10) }),
fc.array(fc.integer({ min: 0, max: 1000 }), {
minLength: 0,
maxLength: 50,
}),
(capacity, refillRate, delays) => {
let t = 0;
const clock = () => t;
const bucket = new TokenBucket({
capacity,
refillRate,
clock,
});
for (const delay of delays) {
t += delay;
bucket.consume();
}
// After any sequence, available must be <= capacity
expect(bucket.available).toBeLessThanOrEqual(capacity);
}
)
);
});
it("available never goes below 0", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 100 }),
fc.float({ min: Math.fround(0.001), max: Math.fround(1) }),
fc.integer({ min: 1, max: 200 }),
(capacity, refillRate, consumeCount) => {
const t = 0;
const clock = () => t;
const bucket = new TokenBucket({
capacity,
refillRate,
initialTokens: 0,
clock,
});
for (let i = 0; i < consumeCount; i++) {
bucket.consume();
}
expect(bucket.available).toBeGreaterThanOrEqual(0);
}
)
);
});
it("consume(n) decreases available by exactly n when allowed", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 100 }),
fc.integer({ min: 1, max: 10 }),
(capacity, consume) => {
fc.pre(consume <= capacity);
const t = 0;
const clock = () => t;
const bucket = new TokenBucket({
capacity,
refillRate: 0,
clock,
});
const before = bucket.available;
const result = bucket.consume(consume);
if (result.allowed) {
expect(bucket.available).toBe(before - consume);
}
}
)
);
});
});
describe("Property: tenant multiplier correctness", () => {
it("effective limit = base * multiplier for same key", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 2, max: 10 }),
fc.integer({ min: 2, max: 4 }),
async (baseLimit, multiplierInt) => {
const multiplier = multiplierInt; // integer multiplier avoids float rounding
const effectiveLimit = baseLimit * multiplier;
const t = 0;
const clock = () => t;
const limiter = createRateLimiter({
defaultConfig: { limit: baseLimit, window: 60_000 },
enableTenantLimits: true,
tenantMultipliers: { "test-tenant": multiplier },
clock,
});
const key = `tenant-same-key-${baseLimit}-${multiplier}`;
let allowed = 0;
for (let i = 0; i < effectiveLimit + 3; i++) {
const r = await limiter.check({
key,
tenantId: "test-tenant",
});
if (r.allowed) allowed++;
}
// Exactly effectiveLimit requests on the same key should be allowed
expect(allowed).toBe(effectiveLimit);
}
)
);
});
});
describe("Property: SlidingWindow monotonicity", () => {
it("remaining never increases between consecutive checks without reset", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 50 }),
fc.integer({ min: 100, max: 10_000 }),
fc.integer({ min: 1, max: 100 }),
(limit, windowMs, checks) => {
const t = 1_000_000;
const clock = () => t;
const sw = new SlidingWindow({ limit, windowMs, clock });
let lastRemaining = limit;
let wasReset = false;
for (let i = 0; i < checks; i++) {
const r = sw.check("monotone-key");
if (r.allowed) {
// remaining should be <= previous remaining (or equal if we're at 0)
if (!wasReset) {
expect(r.remaining).toBeLessThanOrEqual(
lastRemaining
);
}
}
lastRemaining = r.remaining;
wasReset = false;
}
}
)
);
});
});
describe("Property: MemoryStorage round-trip", () => {
it("set then get returns deep-equal state within TTL", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }),
fc.integer({ min: 0, max: 1000 }),
async (key, count) => {
const t = 1_000_000;
const clock = () => t;
const storage = new MemoryStorage({ clock });
const state: RateLimitState = {
key,
algorithm: "sliding-window",
state: {
requests: Array.from(
{ length: count % 10 },
(_, i) => t - i * 100
),
},
config: { limit: 100, window: 60_000 },
updatedAt: t,
};
await storage.set(key, state, 60_000);
const loaded = await storage.get(key);
expect(loaded).toEqual(state);
}
)
);
});
it("get returns null after TTL expires", { timeout: 30000 }, async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
fc.integer({ min: 1, max: 5000 }),
async (key, ttl) => {
let t = 0;
const clock = () => t;
const storage = new MemoryStorage({ clock });
const state: RateLimitState = {
key,
algorithm: "fixed-window",
state: { count: 1, windowStart: 0 },
config: { limit: 10, window: ttl },
updatedAt: 0,
};
await storage.set(key, state, ttl);
t = ttl + 1;
expect(await storage.get(key)).toBeNull();
}
)
);
});
});
describe("Property: rule priority ordering", () => {
it("higher priority rule always wins over lower", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 50 }),
fc.integer({ min: 51, max: 100 }),
fc.integer({ min: 1, max: 20 }),
fc.integer({ min: 21, max: 100 }),
async (lowLimit, highLimit, lowPriority, highPriority) => {
const t = 0;
const clock = () => t;
const limiter = createRateLimiter({
defaultConfig: { limit: 1000, window: 60_000 },
rules: [
{
id: "low",
limit: lowLimit,
window: 60_000,
priority: lowPriority,
conditions: [
{
field: "path",
operator: "eq",
value: "/test",
},
],
},
{
id: "high",
limit: highLimit,
window: 60_000,
priority: highPriority,
conditions: [
{
field: "path",
operator: "eq",
value: "/test",
},
],
},
],
clock,
});
// Use unique key per test to avoid cross-run contamination
const key = `prop-${lowLimit}-${highPriority}`;
let allowed = 0;
for (let i = 0; i < highLimit + 5; i++) {
const r = await limiter.check({
key: `${key}-${i}`,
path: "/test",
});
if (r.allowed) allowed++;
}
// All requests on unique keys should pass (one request per unique key)
expect(allowed).toBe(highLimit + 5);
}
)
);
});
});

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/rate-limiter",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/logger",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/constants/**",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});