Files
suaveplan/openspec/archive/2026-04-24-enterprise-rate-limiter/proposal.md
2026-05-27 23:29:53 +01:00

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 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

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.