# `services/rate-limit-service` — Plugin-level rate-limit service factory ## Purpose Wraps the universal `@suaveplan/rate-limiter` core in the plugin's policy layer: IP whitelist, tenant bypass, tenant multiplier applied to the numeric limit, parameterised route matching, and OTel instrumentation. Exposed in the host `ServiceRegistry` under the `rateLimitService` slot so other plugins and route handlers can call `service.check({ ip, tenantId, path, method })` directly without going through the Hono middleware. This service is the public extension point of the plugin — operators who want a custom storage backend, a custom key resolver, or fine-grained per-route policy use the same surface the middleware itself uses. ## Features - IP whitelist short-circuits the entire check (bypassed counter recorded, no storage write). - Tenant bypass short-circuits per-tenant. - Tenant multiplier multiplies the matched rule's numeric `limit` and is wired into the algorithm itself (one limiter per (route, tenant) pair, lazily constructed). - Route lookup respects `method` when present and uses `compilePathPattern` for the `path`. - Storage failure is wrapped as `RateLimitPluginError(STORAGE_FAILURE)` so consumers can decide fail-open vs fail-closed. - OTel counters and histograms recorded on every code path (allowed / denied / bypassed). ## Usage: Basic ```ts import { createDefaultStorage, createRateLimitService, RateLimitPluginOptionsSchema, } from "@suaveplan/api-plugin-rate-limit"; const config = RateLimitPluginOptionsSchema.parse({ defaultLimit: 100, defaultWindowMs: 60_000, }); const service = createRateLimitService({ config, storage: createDefaultStorage({ clock: Date.now }), clock: Date.now, logger: console, }); const decision = await service.check({ ip: "127.0.0.1", path: "/api/users", method: "GET", }); ``` ## Usage: Advanced ```ts import { createRateLimitService } from "@suaveplan/api-plugin-rate-limit"; const service = createRateLimitService({ config: { defaultLimit: 100, defaultWindowMs: 60_000, routes: [ { path: "/api/login", method: "POST", limit: 5, windowMs: 60_000, id: "login" }, ], tenants: [ { tenantId: "enterprise", multiplier: 10 }, { tenantId: "internal", bypass: true }, ], ipWhitelist: ["127.0.0.1"], keyResolver: (input) => `${input.tenantId ?? "_"}::${input.ip}`, headerStyle: "rfc-9238", emitRetryAfter: true, }, storage, clock, logger, }); const decision = await service.check({ ip: "10.0.0.42", tenantId: "enterprise", path: "/api/login", method: "POST", }); if (!decision.allowed) { return new Response("rate limit exceeded", { status: 429 }); } ``` ## API Reference - `interface RateLimitService` — `{ check, reset, config }`. - `interface RateLimitCheckInput` — `{ ip, tenantId?, path, method }`. - `function createRateLimitService(args: { config, storage, clock, logger }): RateLimitService`. - `function createDefaultStorage(args: { clock }): RateLimitStorage` — `MemoryStorage` wrapped with the injected clock. ## Implementation Notes - Lazy limiter construction (one per `(routeId, tenantId)` pair) keeps init cost proportional to the configured routes, not the number of active tenants. The first hit by tenant `T` against route `R` builds a fresh limiter; subsequent hits reuse it. - The reset path iterates every limiter built so far — operators calling `service.reset(key)` after a successful login pay `O(routes × tenants_seen)` for the broadcast. In practice both factors are small. - The default route (no rule matched) uses `id: "default"` and the plugin's `defaultLimit` / `defaultWindowMs`. It is treated as a rule like any other so the tenant multiplier and storage isolation apply uniformly. - OTel attributes always carry the `routeId` and (when present) the `tenantId` — alerts can filter by either dimension without re-aggregation. - `RateLimitPluginError(STORAGE_FAILURE)` carries the original error as `cause` so structured logs preserve the stack trace.