first commit
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# `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.
|
||||
Reference in New Issue
Block a user