Initial Commit
This commit is contained in:
87
packages/foundation/flow/rate-limiter/README.md
Normal file
87
packages/foundation/flow/rate-limiter/README.md
Normal file
File diff suppressed because one or more lines are too long
88
packages/foundation/flow/rate-limiter/package.json
Normal file
88
packages/foundation/flow/rate-limiter/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
packages/foundation/flow/rate-limiter/src/constants/index.ts
Normal file
20
packages/foundation/flow/rate-limiter/src/constants/index.ts
Normal 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;
|
||||
@@ -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.
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FixedWindow } from "./fixed-window.js";
|
||||
export type { FixedWindowOptions } from "./fixed-window.types.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { createRateLimiter } from "./rate-limiter.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
RateLimitCondition,
|
||||
RateLimitConfig,
|
||||
RateLimitContext,
|
||||
RateLimitedPayload,
|
||||
RateLimiterEvents,
|
||||
RateLimiterInstance,
|
||||
RateLimiterOptions,
|
||||
RateLimitResetPayload,
|
||||
RateLimitResult,
|
||||
RateLimitRule,
|
||||
RateLimitState,
|
||||
TenantRateLimitConfig,
|
||||
} from "../../types/rate-limiter.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SlidingWindow } from "./sliding-window.js";
|
||||
export type { SlidingWindowOptions } from "./sliding-window.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TokenBucket } from "./token-bucket.js";
|
||||
export type { TokenBucketOptions } from "./token-bucket.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
RATE_CODES,
|
||||
RateLimiterError,
|
||||
type RateLimiterErrorCode,
|
||||
} from "./rate-limiter-error.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
49
packages/foundation/flow/rate-limiter/src/index.ts
Normal file
49
packages/foundation/flow/rate-limiter/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createIpRateLimiter,
|
||||
createRateLimitChecker,
|
||||
createRouteRateLimiter,
|
||||
getRateLimitResult,
|
||||
} from "./rate-limit-middleware.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
MiddlewareFn,
|
||||
RateLimiterMiddlewareOptions,
|
||||
} from "../../types/rate-limiter.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { RateLimitStorage } from "./adapter.js";
|
||||
@@ -0,0 +1 @@
|
||||
export type { RateLimitStorage } from "./adapter.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { MemoryStorage, type MemoryStorageOptions } from "./memory-storage.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
139
packages/foundation/flow/rate-limiter/tests/integration.test.ts
Normal file
139
packages/foundation/flow/rate-limiter/tests/integration.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
281
packages/foundation/flow/rate-limiter/tests/property.test.ts
Normal file
281
packages/foundation/flow/rate-limiter/tests/property.test.ts
Normal 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
5
packages/foundation/flow/rate-limiter/tsconfig.json
Normal file
5
packages/foundation/flow/rate-limiter/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
12
packages/foundation/flow/rate-limiter/tsconfig.lib.json
Normal file
12
packages/foundation/flow/rate-limiter/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
39
packages/foundation/flow/rate-limiter/vite.config.ts
Normal file
39
packages/foundation/flow/rate-limiter/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/foundation/flow/rate-limiter/vitest.config.ts
Normal file
33
packages/foundation/flow/rate-limiter/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user