4.1 KiB
4.1 KiB
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
limitand is wired into the algorithm itself (one limiter per (route, tenant) pair, lazily constructed). - Route lookup respects
methodwhen present and usescompilePathPatternfor thepath. - 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
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
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—MemoryStoragewrapped 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 tenantTagainst routeRbuilds 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 payO(routes × tenants_seen)for the broadcast. In practice both factors are small. - The default route (no rule matched) uses
id: "default"and the plugin'sdefaultLimit/defaultWindowMs. It is treated as a rule like any other so the tenant multiplier and storage isolation apply uniformly. - OTel attributes always carry the
routeIdand (when present) thetenantId— alerts can filter by either dimension without re-aggregation. RateLimitPluginError(STORAGE_FAILURE)carries the original error ascauseso structured logs preserve the stack trace.