373 lines
11 KiB
Markdown
373 lines
11 KiB
Markdown
# OpenSpec Change: enterprise-rate-limiter
|
|
|
|
- **Change ID**: enterprise-rate-limiter
|
|
- **Package**: `@suaveplan/rate-limiter`
|
|
- **Category**: universal
|
|
- **Target path**: `universal/auth/rate-limiter`
|
|
- **Status**: PENDING
|
|
- **Date**: 2026-04-24
|
|
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/rate-limiter.md`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Deliver `@suaveplan/rate-limiter` on the genesis branch from scratch at enterprise quality. The package provides tenant-specific rate limiting with multiple algorithms: `TokenBucket` (burst-friendly), `SlidingWindow` (smooth), and `FixedWindow` (simple); a `RateLimiter` orchestrator that applies rule-based routing to the right algorithm; pluggable `RateLimitStorage` adapters (`MemoryStorage`, adapter interface); multi-tenant multipliers; and framework-agnostic middleware helpers. The genesis rewrite removes Hono coupling from the core types, adds structured errors via `@suaveplan/error`, Zod schemas, injectable clock and RNG for determinism, OTel instrumentation, `AbortSignal` on async storage paths, property-based tests, and mitata benchmarks.
|
|
|
|
---
|
|
|
|
## Current-state audit (genesis)
|
|
|
|
The package does not exist on genesis yet — this is a fresh build.
|
|
|
|
---
|
|
|
|
## Available workspace deps on genesis
|
|
|
|
| Package | Role |
|
|
|---|---|
|
|
| `@suaveplan/error` (`workspace:^`) | Base class for `RateLimiterError`; `defineCodes` for `RATE.*` error codes |
|
|
| `@suaveplan/types` (`workspace:^`) | `Clock` (injectable window computation), `Rng` (injectable jitter), `LoggerLike`, `EventBusLike` |
|
|
| `@suaveplan/events` (`workspace:^`) | `EventBus` for `rate:limit:exceeded` events |
|
|
| `@suaveplan/logger` (`workspace:^`) | Structured logging with trace correlation |
|
|
| `zod` (`catalog:forms`) | Zod schemas for all option types |
|
|
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms |
|
|
| `tslib` (`catalog:build`) | TypeScript runtime helpers |
|
|
|
|
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`), `@suaveplan/testing` (`workspace:^`).
|
|
|
|
---
|
|
|
|
## Preserved public API surface (from main branch)
|
|
|
|
### Core types
|
|
|
|
```ts
|
|
interface RateLimitResult {
|
|
readonly allowed: boolean
|
|
readonly remaining: number
|
|
readonly limit: number
|
|
readonly resetIn: number // ms until window resets
|
|
readonly retryAfter?: number // ms to wait (only when allowed === false)
|
|
readonly metadata?: Record<string, unknown>
|
|
}
|
|
|
|
interface RateLimitConfig {
|
|
readonly limit: number
|
|
readonly window: number // ms
|
|
readonly id?: string
|
|
readonly description?: string
|
|
}
|
|
|
|
interface RateLimitRule extends RateLimitConfig {
|
|
readonly priority?: number
|
|
readonly conditions?: RateLimitCondition[]
|
|
readonly bypass?: boolean
|
|
}
|
|
|
|
interface RateLimitCondition {
|
|
readonly field: string
|
|
readonly operator: "eq" | "neq" | "in" | "nin" | "matches"
|
|
readonly value: unknown
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// State shapes (serialisable for persistence)
|
|
interface TokenBucketState {
|
|
tokens: number
|
|
lastRefill: number // epoch ms
|
|
}
|
|
|
|
interface SlidingWindowState {
|
|
requests: number[] // array of epoch ms timestamps
|
|
}
|
|
|
|
interface CounterState {
|
|
count: number
|
|
windowStart: number // epoch ms
|
|
}
|
|
|
|
interface RateLimitState {
|
|
key: string
|
|
algorithm: RateLimitAlgorithm
|
|
state: TokenBucketState | SlidingWindowState | CounterState
|
|
config: RateLimitConfig
|
|
updatedAt: number
|
|
}
|
|
|
|
type RateLimitAlgorithm = "token-bucket" | "sliding-window" | "fixed-window"
|
|
|
|
interface TenantRateLimitConfig extends RateLimitConfig {
|
|
tenantId: string
|
|
multiplier?: number
|
|
}
|
|
|
|
// Events
|
|
interface RateLimiterEvents {
|
|
'rate:limit:exceeded': RateLimitedPayload
|
|
'rate:limit:reset': RateLimitResetPayload
|
|
}
|
|
|
|
interface RateLimitedPayload {
|
|
key: string
|
|
tenantId?: string
|
|
remaining: number
|
|
resetIn: number
|
|
}
|
|
|
|
interface RateLimitResetPayload {
|
|
key: string
|
|
tenantId?: string
|
|
}
|
|
```
|
|
|
|
### Storage adapter
|
|
|
|
```ts
|
|
interface RateLimitStorage {
|
|
get(key: string): Promise<RateLimitState | null>
|
|
set(key: string, state: RateLimitState, ttl: number): Promise<void>
|
|
delete(key: string): Promise<void>
|
|
clear(): Promise<void>
|
|
}
|
|
|
|
interface MemoryStorageOptions {
|
|
maxSize?: number
|
|
clock?: Clock
|
|
}
|
|
|
|
class MemoryStorage implements RateLimitStorage {
|
|
constructor(options?: MemoryStorageOptions)
|
|
}
|
|
```
|
|
|
|
### Core algorithms
|
|
|
|
```ts
|
|
interface TokenBucketOptions {
|
|
capacity: number
|
|
refillRate: number // tokens per ms
|
|
initialTokens?: number
|
|
clock?: Clock
|
|
}
|
|
|
|
class TokenBucket {
|
|
constructor(options: TokenBucketOptions)
|
|
consume(tokens?: number): RateLimitResult
|
|
refill(tokens?: number): void
|
|
readonly available: number
|
|
readonly capacity: number
|
|
}
|
|
|
|
interface SlidingWindowOptions {
|
|
limit: number
|
|
windowMs: number
|
|
clock?: Clock
|
|
}
|
|
|
|
class SlidingWindow {
|
|
constructor(options: SlidingWindowOptions)
|
|
check(key: string): RateLimitResult
|
|
reset(key: string): void
|
|
}
|
|
```
|
|
|
|
### Middleware helpers
|
|
|
|
```ts
|
|
function createRateLimiter(options: RateLimiterOptions): RateLimiterInstance
|
|
function createIpRateLimiter(config: RateLimitConfig, storage?: RateLimitStorage): MiddlewareFn
|
|
function createRouteRateLimiter(routes: Record<string, RateLimitConfig>, storage?: RateLimitStorage): MiddlewareFn
|
|
function createRateLimitChecker(options: RateLimiterOptions): (context: RateLimitContext) => Promise<RateLimitResult>
|
|
function getRateLimitResult(context: RateLimitContext, storage: RateLimitStorage, config: RateLimitConfig): Promise<RateLimitResult>
|
|
|
|
interface RateLimiterMiddlewareOptions {
|
|
keyExtractor?: (req: unknown) => string
|
|
onLimited?: (result: RateLimitResult) => void
|
|
skip?: (req: unknown) => boolean
|
|
}
|
|
|
|
interface RateLimiterInstance {
|
|
check(context: RateLimitContext): Promise<RateLimitResult>
|
|
reset(key: string): Promise<void>
|
|
getState(key: string): Promise<RateLimitState | null>
|
|
}
|
|
```
|
|
|
|
### Error codes
|
|
|
|
```ts
|
|
type RateLimiterErrorCode =
|
|
| "RATE_STORAGE_ERROR"
|
|
| "RATE_INVALID_CONFIG"
|
|
| "RATE_KEY_MISSING"
|
|
| "RATE_STATE_CORRUPT"
|
|
|
|
class RateLimiterError extends SuaveplanError<RateLimiterErrorCode>
|
|
function registerRateLimiterErrorCodes(): void
|
|
```
|
|
|
|
---
|
|
|
|
## New additions for genesis
|
|
|
|
### 1. FixedWindow algorithm
|
|
|
|
A third algorithm (`FixedWindow`) complements `TokenBucket` and `SlidingWindow`. Uses a simple counter with a hard window boundary — lowest memory overhead, suitable for coarse limits.
|
|
|
|
```ts
|
|
interface FixedWindowOptions {
|
|
limit: number
|
|
windowMs: number
|
|
clock?: Clock
|
|
}
|
|
|
|
class FixedWindow {
|
|
constructor(options: FixedWindowOptions)
|
|
check(key: string): RateLimitResult
|
|
reset(key: string): void
|
|
}
|
|
```
|
|
|
|
### 2. Clock and Rng injection
|
|
|
|
`TokenBucket` refill timing and `SlidingWindow` / `FixedWindow` window boundaries use injectable `Clock` (from `@suaveplan/types`). No bare `Date.now()` at module scope. `MemoryStorage` TTL checks also use injectable `Clock`.
|
|
|
|
### 3. `RateLimiterInstance` orchestrator
|
|
|
|
`createRateLimiter` returns a `RateLimiterInstance` that:
|
|
- Selects algorithm (token-bucket / sliding-window / fixed-window) per rule or default config.
|
|
- Applies `rules` in priority order — first matching rule wins; bypass rules skip limiting.
|
|
- Applies `tenantMultipliers` to effective limit for matching tenant contexts.
|
|
- Emits `rate:limit:exceeded` on optional `bus` when `allowed === false`.
|
|
- Emits `rate:limit:reset` on `reset()`.
|
|
|
|
### 4. OTel instrumentation
|
|
|
|
- `rate_limiter.check` span + `rate_limiter.checks_total` counter (labels: `algorithm`, `allowed: bool`, `tenant_id`)
|
|
- `rate_limiter.check_duration_ms` histogram
|
|
- `rate_limiter.exceeded_total` counter (label: `key_prefix`, `tenant_id`)
|
|
- `rate_limiter.storage_get_total` / `rate_limiter.storage_set_total` counters
|
|
|
|
### 5. Zod schemas
|
|
|
|
`RateLimitConfigSchema`, `RateLimitRuleSchema`, `RateLimitContextSchema`, `RateLimiterOptionsSchema`, `TokenBucketOptionsSchema`, `SlidingWindowOptionsSchema`, `FixedWindowOptionsSchema`, `MemoryStorageOptionsSchema`.
|
|
|
|
### 6. Property-based tests (fast-check)
|
|
|
|
- TokenBucket invariant: `available` never exceeds `capacity`; `available` never goes below 0.
|
|
- SlidingWindow monotonicity: `remaining` never increases within a window without a `reset`.
|
|
- Tenant multiplier correctness: effective limit for tenant with multiplier `m` equals `config.limit * m`.
|
|
- Rule priority ordering: when two rules match, the one with higher `priority` is applied.
|
|
- Storage round-trip: `set(key, state)` then `get(key)` returns a deep-equal state (within TTL).
|
|
|
|
### 7. Benchmark suite
|
|
|
|
- `TokenBucket.consume` — 1 000 000 iterations (pure in-memory).
|
|
- `SlidingWindow.check` — 100 000 iterations with a 1 000-entry timestamp window.
|
|
- `FixedWindow.check` — 100 000 iterations.
|
|
- `createRateLimiter.check` with MemoryStorage — 100 000 iterations.
|
|
- `MemoryStorage.get` + `set` — 1 000 000 round-trips.
|
|
|
|
---
|
|
|
|
## File layout
|
|
|
|
```text
|
|
universal/auth/rate-limiter/
|
|
package.json
|
|
tsconfig.json
|
|
vitest.config.ts
|
|
src/
|
|
index.ts
|
|
error-codes.ts
|
|
constants/
|
|
index.ts
|
|
types/
|
|
rate-limiter.types.ts
|
|
rate-limiter.types.md
|
|
core/
|
|
token-bucket/
|
|
token-bucket.ts
|
|
token-bucket.md
|
|
token-bucket.types.ts
|
|
token-bucket.test.ts
|
|
index.ts
|
|
sliding-window/
|
|
sliding-window.ts
|
|
sliding-window.md
|
|
sliding-window.types.ts
|
|
sliding-window.test.ts
|
|
index.ts
|
|
fixed-window/
|
|
fixed-window.ts
|
|
fixed-window.md
|
|
fixed-window.types.ts
|
|
fixed-window.test.ts
|
|
index.ts
|
|
rate-limiter/
|
|
rate-limiter.ts # createRateLimiter + RateLimiterInstance
|
|
rate-limiter.md
|
|
rate-limiter.types.ts
|
|
rate-limiter.test.ts
|
|
index.ts
|
|
storage/
|
|
storage-adapter/
|
|
storage-adapter.ts # RateLimitStorage interface
|
|
storage-adapter.md
|
|
storage-adapter.types.ts
|
|
index.ts
|
|
memory-storage/
|
|
memory-storage.ts
|
|
memory-storage.md
|
|
memory-storage.test.ts
|
|
index.ts
|
|
middleware/
|
|
rate-limit-middleware/
|
|
rate-limit-middleware.ts # createIpRateLimiter, createRouteRateLimiter, etc.
|
|
rate-limit-middleware.md
|
|
rate-limit-middleware.types.ts
|
|
rate-limit-middleware.test.ts
|
|
index.ts
|
|
schemas/
|
|
rate-limiter.schemas.ts
|
|
rate-limiter.schemas.md
|
|
rate-limiter.schemas.test.ts
|
|
__benchmarks__/
|
|
rate-limiter.bench.ts
|
|
tests/
|
|
integration.test.ts
|
|
property.test.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Impact
|
|
|
|
**Downstream packages**: API gateway and server packages will use `createRateLimiter` for request throttling. Auth plugins will use the tenant-specific configuration.
|
|
|
|
**Breaking changes from main-branch API**: Hono-specific types in `RateLimiterMiddlewareOptions` are replaced with framework-agnostic `MiddlewareFn`; a `createHonoAdapter` bridge is exported for compat. All other symbols are preserved. `FixedWindow` is a net-new addition.
|
|
|
|
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger` must be published to Verdaccio before this package can be built or tested.
|