Files
2026-05-27 23:29:53 +01:00

12 KiB

Tasks: enterprise-rate-limiter

Change ID: enterprise-rate-limiter Package: @suaveplan/rate-limiter Target path: universal/auth/rate-limiter Prerequisite packages on Verdaccio: @suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/logger


1. Scaffold

  • Create directory universal/auth/rate-limiter/ with src/, tests/, src/__benchmarks__/, and all feature subdirectories per the layout in proposal.md.
  • Write package.json at version 0.1.0 with:
    • "type": "module", "sideEffects": false
    • exports map with top-level "." plus subpaths: "./core/token-bucket/token-bucket", "./core/sliding-window/sliding-window", "./core/fixed-window/fixed-window", "./core/rate-limiter/rate-limiter", "./storage/storage-adapter/storage-adapter", "./storage/memory-storage/memory-storage", "./middleware/rate-limit-middleware/rate-limit-middleware", "./schemas/rate-limiter.schemas", "./types/rate-limiter.types"
    • dependencies: @suaveplan/error workspace:^, @suaveplan/types workspace:^, @suaveplan/events workspace:^, @suaveplan/logger workspace:^, zod catalog:forms, @opentelemetry/api catalog:observability, tslib catalog:build
    • devDependencies: vitest catalog:testing, @vitest/coverage-istanbul catalog:testing, fast-check catalog:testing, mitata catalog:testing, @suaveplan/testing workspace:^
  • Write tsconfig.json extending @suaveplan/typescript-config/library.json.
  • Write vitest.config.ts using createTestConfig() from @suaveplan/testing/config (pool: forks).
  • Add lint, typecheck, test, test:coverage, build, docs:check, bench scripts to package.json.

2. Types and errors

2a. Types (src/types/rate-limiter.types.ts + .md)

  • Define all public interfaces from proposal.md: RateLimitResult, RateLimitConfig, RateLimitRule, RateLimitCondition, RateLimitContext, RateLimiterOptions, TokenBucketState, SlidingWindowState, CounterState, RateLimitState, RateLimitAlgorithm, TenantRateLimitConfig, RateLimiterEvents, RateLimitedPayload, RateLimitResetPayload, RateLimiterInstance, RateLimiterMiddlewareOptions, MiddlewareFn.
  • Write rate-limiter.types.md (≥200 words, API reference for every type).

2b. Error codes (src/error-codes.ts)

  • Define RateLimiterErrorCode union — four codes: RATE_STORAGE_ERROR, RATE_INVALID_CONFIG, RATE_KEY_MISSING, RATE_STATE_CORRUPT.
  • Define RATE_ERROR_METADATA with httpStatus, severity, retryable, userMessageKey, category, description per code.
  • Export registerRateLimiterErrorCodes() as idempotent eager side-effect.
  • Export RateLimiterError extends SuaveplanError<RateLimiterErrorCode>.

2c. Zod schemas (src/schemas/rate-limiter.schemas.ts + .md)

  • Write Zod schemas: RateLimitConfigSchema, RateLimitRuleSchema, RateLimitContextSchema, RateLimiterOptionsSchema, TokenBucketOptionsSchema, SlidingWindowOptionsSchema, FixedWindowOptionsSchema, MemoryStorageOptionsSchema.
  • Write rate-limiter.schemas.md.
  • Write rate-limiter.schemas.test.ts — parse valid/invalid for each schema.

3. Storage adapter (src/storage/storage-adapter/)

  • Define RateLimitStorage interface: get, set, delete, clear.
  • Write storage-adapter.types.ts.
  • Write storage-adapter.md.

4. MemoryStorage (src/storage/memory-storage/)

  • Implement MemoryStorage using a Map<string, { state: RateLimitState; expiresAt: number }>:
    • get(key) — returns null if missing or expired (lazy check via injectable clock).
    • set(key, state, ttl) — stores with expiresAt = clock.now() + ttl.
    • delete(key) — removes entry.
    • clear() — wipes map.
    • maxSize enforcement: on set, if map exceeds maxSize, evict the entry with the soonest expiresAt.
  • Write memory-storage.test.ts — get/set round-trip, TTL expiry with FakeClock, maxSize eviction, delete, clear. Each it has { timeout: 5000 }.
  • Write memory-storage.md.

5. TokenBucket (src/core/token-bucket/)

  • Implement TokenBucket(options):
    • Internal state: tokens: number, lastRefill: number (epoch ms via clock).
    • consume(tokens?):
      • First, compute elapsed ms since lastRefill via clock.now(); add elapsed * refillRate tokens (capped at capacity).
      • If tokens available, decrement and return { allowed: true, remaining: ..., limit: capacity, resetIn: timeUntilNextToken }.
      • If insufficient, return { allowed: false, remaining: 0, limit: capacity, resetIn: ..., retryAfter: ... }.
    • refill(tokens?) — explicitly adds tokens (used in tests and manual top-ups).
    • Getters: available, capacity.
    • Validate options with TokenBucketOptionsSchema; throw RateLimiterError with RATE_INVALID_CONFIG on invalid options.
  • Write token-bucket.test.ts — consume within capacity, consume exhausted, automatic refill via FakeClock, explicit refill, available getter, capacity cap, invalid options error. Each it has { timeout: 5000 }.
  • Write token-bucket.md.

6. SlidingWindow (src/core/sliding-window/)

  • Implement SlidingWindow(options):
    • Internal state: Map<string, number[]> — per-key array of request timestamps.
    • check(key):
      • Prune timestamps older than clock.now() - windowMs.
      • If remaining > 0, push current timestamp, return { allowed: true, remaining: ... }.
      • Otherwise return { allowed: false, remaining: 0, retryAfter: oldest_ts + windowMs - now }.
    • reset(key) — clears the key's timestamp array.
    • Validate options with SlidingWindowOptionsSchema.
  • Write sliding-window.test.ts — allow up to limit, deny on exceed, timestamps pruned outside window (FakeClock), reset, multiple keys isolated. Each it has { timeout: 5000 }.
  • Write sliding-window.md.

7. FixedWindow (src/core/fixed-window/)

  • Implement FixedWindow(options):
    • Internal state: Map<string, { count: number; windowStart: number }>.
    • check(key):
      • Compute current window start = Math.floor(clock.now() / windowMs) * windowMs.
      • If stored windowStart !== currentWindowStart, reset count to 0 and update windowStart.
      • If count < limit, increment and return { allowed: true, remaining: limit - count - 1, resetIn: currentWindowStart + windowMs - clock.now() }.
      • Otherwise return { allowed: false, remaining: 0, retryAfter: currentWindowStart + windowMs - clock.now() }.
    • reset(key) — removes the key's state.
    • Validate options with FixedWindowOptionsSchema.
  • Write fixed-window.test.ts — allow within limit, deny on exceed, window rolls over (FakeClock), reset, multiple keys. Each it has { timeout: 5000 }.
  • Write fixed-window.md.

8. RateLimiter orchestrator (src/core/rate-limiter/)

  • Implement createRateLimiter(options) returning RateLimiterInstance:
    • check(context):
      1. Evaluate options.rules in descending priority order; first matching rule (conditions met) wins.
      2. If a rule has bypass: true, return { allowed: true, remaining: Infinity, limit: Infinity, resetIn: 0 }.
      3. Apply tenantMultipliers to effective limit if context.tenantId matches.
      4. Build a composite storage key: ${keyPrefix}:${context.key}.
      5. Delegate to the selected algorithm (default: sliding-window; configurable per-rule via rule.algorithm).
      6. If !result.allowed, emit rate:limit:exceeded on bus.
      7. Instrument with rate_limiter.check span, rate_limiter.checks_total counter, rate_limiter.check_duration_ms histogram.
    • reset(key) — deletes all storage keys matching the composite key pattern; emits rate:limit:reset.
    • getState(key) — delegates to storage.get.
  • Write rate-limiter.test.ts (≤500 lines) — default algorithm, rule priority selection, bypass rule, tenant multiplier, bus event on denied, storage delegation, reset, getState. { timeout: 5000 } / { timeout: 30000 }.
  • Write rate-limiter.md.

9. Middleware helpers (src/middleware/rate-limit-middleware/)

  • Implement createRateLimitChecker(options) — returns (context) => Promise<RateLimitResult> closure over a RateLimiterInstance.
  • Implement createIpRateLimiter(config, storage?) — creates a limiter keyed on IP with default sliding-window.
  • Implement createRouteRateLimiter(routes, storage?) — creates a limiter with per-route RateLimitConfig, keyed on path.
  • Implement getRateLimitResult(context, storage, config) — one-shot check without creating a full RateLimiterInstance.
  • All functions return MiddlewareFn (framework-agnostic); include createHonoAdapter bridge.
  • Write rate-limit-middleware.test.ts — each helper: allowed path, denied path, IP extraction, route matching, Hono adapter. Each it has { timeout: 5000 }.
  • Write rate-limit-middleware.md.

10. OTel instrumentation (cross-cutting review)

  • Confirm all spans and counters from proposal.md §4 are wired.
  • Add tests/otel.test.ts — in-memory exporter; assert rate_limiter.checks_total increments, rate_limiter.exceeded_total increments on denied checks, rate_limiter.check_duration_ms records.

11. Property-based tests (tests/property.test.ts)

Use fast-check. All test timeouts { timeout: 30000 }.

  • TokenBucket available invariant: after any sequence of consume and refill calls, available is always in [0, capacity].
  • SlidingWindow monotonicity: within a window, remaining never increases without a reset or window rollover.
  • Tenant multiplier correctness: effective limit = config.limit * multiplier; remaining at the effective limit is 0.
  • Rule priority: when two rules both match a context, the higher-priority rule's config is applied.
  • Storage round-trip: MemoryStorage.set(k, s, ttl) then MemoryStorage.get(k) returns a structurally equal state (within TTL).

12. Benchmarks (src/__benchmarks__/rate-limiter.bench.ts)

Use mitata.

  • token_bucket_consume: 1 000 000 consume() calls (pure in-memory, no storage).
  • sliding_window_check: 100 000 check() calls with a 1 000-timestamp window.
  • fixed_window_check: 100 000 check() calls.
  • rate_limiter_memory_storage: 100 000 check() calls via createRateLimiter + MemoryStorage.
  • memory_storage_get_set: 1 000 000 round-trips.
  • Document placeholder numbers in README.md under ## Benchmarks.

13. Docs

Per-file .md (all ≥200 words):

  • src/types/rate-limiter.types.md
  • src/storage/storage-adapter/storage-adapter.md
  • src/storage/memory-storage/memory-storage.md
  • src/core/token-bucket/token-bucket.md
  • src/core/sliding-window/sliding-window.md
  • src/core/fixed-window/fixed-window.md
  • src/core/rate-limiter/rate-limiter.md
  • src/middleware/rate-limit-middleware/rate-limit-middleware.md
  • src/schemas/rate-limiter.schemas.md

Root README:

  • Write universal/auth/rate-limiter/README.md: purpose, install, quick-start (token-bucket + sliding-window comparison), multi-tenant guide, middleware adapter guide, modules table, benchmarks placeholder.

14. Verify and publish

  • Run bunx turbo lint --filter=@suaveplan/rate-limiter — zero errors.
  • Run bunx turbo typecheck --filter=@suaveplan/rate-limiter — zero errors.
  • Run bunx turbo test --filter=@suaveplan/rate-limiter — all tests pass.
  • Run bunx turbo test:coverage --filter=@suaveplan/rate-limiter — 100/100/100/100.
  • Run bun run docs:check --filter=@suaveplan/rate-limiter — zero errors.
  • Confirm no TODO, FIXME, @deprecated, untyped any, ts-ignore, or .skip in src/ or tests/.
  • Confirm "sideEffects": false in package.json.
  • Run bun scripts/publish.ts --package @suaveplan/rate-limiter.
  • Update CLAUDE.md package listing to include @suaveplan/rate-limiter.
  • Archive: move openspec/changes/enterprise-rate-limiter/ to openspec/archive/2026-04-24-enterprise-rate-limiter/ and fold spec delta into openspec/specs/rate-limiter/spec.md.