11 KiB
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
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
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
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
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
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.
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
rulesin priority order — first matching rule wins; bypass rules skip limiting. - Applies
tenantMultipliersto effective limit for matching tenant contexts. - Emits
rate:limit:exceededon optionalbuswhenallowed === false. - Emits
rate:limit:resetonreset().
4. OTel instrumentation
rate_limiter.checkspan +rate_limiter.checks_totalcounter (labels:algorithm,allowed: bool,tenant_id)rate_limiter.check_duration_mshistogramrate_limiter.exceeded_totalcounter (label:key_prefix,tenant_id)rate_limiter.storage_get_total/rate_limiter.storage_set_totalcounters
5. Zod schemas
RateLimitConfigSchema, RateLimitRuleSchema, RateLimitContextSchema, RateLimiterOptionsSchema, TokenBucketOptionsSchema, SlidingWindowOptionsSchema, FixedWindowOptionsSchema, MemoryStorageOptionsSchema.
6. Property-based tests (fast-check)
- TokenBucket invariant:
availablenever exceedscapacity;availablenever goes below 0. - SlidingWindow monotonicity:
remainingnever increases within a window without areset. - Tenant multiplier correctness: effective limit for tenant with multiplier
mequalsconfig.limit * m. - Rule priority ordering: when two rules match, the one with higher
priorityis applied. - Storage round-trip:
set(key, state)thenget(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.checkwith MemoryStorage — 100 000 iterations.MemoryStorage.get+set— 1 000 000 round-trips.
File layout
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.