Files
skills/docs/api-stack/api/api-plugin-rate-limit/src/services/rate-limit-service.md
2026-05-29 08:20:56 +01:00

4.1 KiB
Raw Blame History

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

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