# 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 } 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 } interface RateLimiterOptions { readonly defaultConfig: RateLimitConfig readonly storage?: RateLimitStorage readonly rules?: RateLimitRule[] readonly enableTenantLimits?: boolean readonly tenantMultipliers?: Record 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 set(key: string, state: RateLimitState, ttl: number): Promise delete(key: string): Promise clear(): Promise } 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, storage?: RateLimitStorage): MiddlewareFn function createRateLimitChecker(options: RateLimiterOptions): (context: RateLimitContext) => Promise function getRateLimitResult(context: RateLimitContext, storage: RateLimitStorage, config: RateLimitConfig): Promise interface RateLimiterMiddlewareOptions { keyExtractor?: (req: unknown) => string onLimited?: (result: RateLimitResult) => void skip?: (req: unknown) => boolean } interface RateLimiterInstance { check(context: RateLimitContext): Promise reset(key: string): Promise getState(key: string): Promise } ``` ### Error codes ```ts type RateLimiterErrorCode = | "RATE_STORAGE_ERROR" | "RATE_INVALID_CONFIG" | "RATE_KEY_MISSING" | "RATE_STATE_CORRUPT" class RateLimiterError extends SuaveplanError 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.