Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# @suaveplan/api-rate-limit — per-route / per-principal / per-tenant / per-IP token-bucket middleware for the @suaveplan/api plugin system. Implements the RequestRateLimiter contract consumed by @suaveplan/api-host's request pipeline and provides a Hono-shaped Middleware that returns 429 + Retry-After on denial. Backed by @suaveplan/rate-limiter's TokenBucket. In-memory store ships by default; the RateLimitStoreLike interface keeps Redis/distributed adapters pluggable.
|
||||
|
||||
Per-route / per-principal / per-tenant / per-IP token-bucket rate-limit
|
||||
middleware for `@suaveplan/api`. Implements the
|
||||
`RequestRateLimiter` contract consumed by `@suaveplan/api-host`'s
|
||||
per-request pipeline (design doc §6 step 4a) and ships a richer
|
||||
`check`-based middleware surface for adapter integrations that want the
|
||||
429 response shape pre-computed.
|
||||
|
||||
Status: Stage-8 keystone (`0.1.0`). In-memory store ships by default;
|
||||
the `RateLimitStoreLike` interface keeps a future Redis adapter
|
||||
pluggable without changing call sites.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `RATELIMIT.*` error-code namespace for the `@suaveplan/api-rate-limit` package and the `RateLimitErr…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `RATELIMIT.*` error-code namespace for the `@suaveplan/api-rate-limit` package and the `RateLimitErr…
|
||||
- [instruments](./src/instruments/instruments.md) — Owns the rate-limit OTel instrument registry. Today the package emits exactly one counter — `api.rate_limit.exceeded_to…
|
||||
- [instruments](./src/instruments/instruments.md) — Owns the rate-limit OTel instrument registry. Today the package emits exactly one counter — `api.rate_limit.exceeded_to…
|
||||
- [middleware](./src/middleware/middleware.md) — The package's centerpiece. `createRateLimitMiddleware` builds a per-request gate around the token-bucket store; the gat…
|
||||
- [middleware](./src/middleware/middleware.md) — The package's centerpiece. `createRateLimitMiddleware` builds a per-request gate around the token-bucket store; the gat…
|
||||
- [selector](./src/selector/selector.md) — Resolves the rate-limit bucket key for a per-request frame, given the route-declared (or host-defaulted) `RateLimitKeyS…
|
||||
- [selector](./src/selector/selector.md) — Resolves the rate-limit bucket key for a per-request frame, given the route-declared (or host-defaulted) `RateLimitKeyS…
|
||||
- [memory-store](./src/store/memory-store/memory-store.md) — Provides the default in-memory implementation of `RateLimitStoreLike`. Backed by `TokenBucket` from `@suaveplan/rate-li…
|
||||
- [memory-store](./src/store/memory-store/memory-store.md) — Provides the default in-memory implementation of `RateLimitStoreLike`. Backed by `TokenBucket` from `@suaveplan/rate-li…
|
||||
- [store-like](./src/store/store-like/store-like.md) — Declares the `RateLimitStoreLike` interface — the storage abstraction behind the per-route token-bucket middleware. Kee…
|
||||
- [store-like](./src/store/store-like/store-like.md) — Declares the `RateLimitStoreLike` interface — the storage abstraction behind the per-route token-bucket middleware. Kee…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why this package exists
|
||||
|
||||
The host pipeline's `RequestRateLimiter.consume(key, weight)` slot is
|
||||
synchronous and intentionally minimal. Plugins want richer policy —
|
||||
`{ rpm, burst, key }` per route, plus a way for the host to declare
|
||||
global defaults in `host.json`. This package owns both ends: the
|
||||
token-bucket store, the key-selector resolution, the OTel counter
|
||||
emission, and the projection back onto the host's narrow `consume`
|
||||
shape.
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Surface |
|
||||
|---|---|
|
||||
| `@suaveplan/api-rate-limit` | Aggregate barrel — every public symbol. |
|
||||
| `@suaveplan/api-rate-limit/middleware` | `createRateLimitMiddleware`, `RateLimitRouteOption`, `RateLimitDecision`, `HostRateLimitDefaults`, `RateLimitRouteOptionSchema`. |
|
||||
| `@suaveplan/api-rate-limit/selector` | `RateLimitKeySelector`, `resolveRateLimitKey`, `RateLimitContext`. |
|
||||
| `@suaveplan/api-rate-limit/store` | `RateLimitStoreLike`, `MemoryRateLimitStore`, `createMemoryRateLimitStore`, `RateLimitBudget`, `RateLimitTakeResult`. |
|
||||
| `@suaveplan/api-rate-limit/instruments` | `createRateLimitInstruments`, `RATE_LIMIT_COUNTER_NAMES`. |
|
||||
| `@suaveplan/api-rate-limit/error-codes` | `RATELIMIT_CODES`, `RateLimitError`, `RateLimitCode`. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
|
||||
|
||||
const mw = createRateLimitMiddleware({
|
||||
hostDefaults: {
|
||||
perTenant: { rpm: 600, burst: 30 },
|
||||
perIp: { rpm: 60, burst: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
const decision = mw.check({
|
||||
plugin: "billing",
|
||||
route: "POST /invoices",
|
||||
option: { rpm: 60, burst: 5, key: "principal" },
|
||||
ctx: requestFrame,
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
return new Response(null, { status: 429, headers: decision.headers });
|
||||
}
|
||||
```
|
||||
|
||||
## Wiring into `@suaveplan/api-host`
|
||||
|
||||
```ts
|
||||
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
|
||||
|
||||
const mw = createRateLimitMiddleware({ /* opts */ });
|
||||
const host = createHost({
|
||||
rateLimit: mw.asRequestRateLimiter({
|
||||
plugin: "billing",
|
||||
route: "POST /invoices",
|
||||
option: { rpm: 60, burst: 5 },
|
||||
keyKind: "principal",
|
||||
}),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Acceptance
|
||||
|
||||
Pre-archive gate:
|
||||
|
||||
```
|
||||
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-rate-limit
|
||||
```
|
||||
|
||||
100% statements/branches/functions/lines (Istanbul).
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable `RATELIMIT.*` error-code namespace for the
|
||||
`@suaveplan/api-rate-limit` package and the `RateLimitError` subclass
|
||||
of `SuaveplanError`. Every code shipped here is asserted (via the
|
||||
`conformance` gate) to be thrown by at least one production
|
||||
path in this package, so the namespace cannot drift away from the
|
||||
implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `defineCodes("RATELIMIT", [...])` builds the registry through
|
||||
`@suaveplan/error` so the global code dictionary picks the entries up
|
||||
via the side-effect import in `src/index.ts`.
|
||||
- `RateLimitError` extends `SuaveplanError<RateLimitCode>` and exposes
|
||||
a public constructor so call sites can `throw new RateLimitError(...)`
|
||||
outside the class declaration scope.
|
||||
- Two codes ship in v1: `EXCEEDED` (the gate denied a request) and
|
||||
`STORE_UNAVAILABLE` (the backing `RateLimitStoreLike` raised a
|
||||
transport error such as a Redis disconnect on a future adapter).
|
||||
- The `cause` chain and `context` map are preserved unchanged from
|
||||
`SuaveplanError`, so OTel span-event adapters carry them through.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RATELIMIT_CODES, RateLimitError } from "@suaveplan/api-rate-limit/error-codes";
|
||||
|
||||
throw new RateLimitError(
|
||||
RATELIMIT_CODES.EXCEEDED,
|
||||
"principal exceeded burst budget",
|
||||
{ context: { key: "principal:alice", retryAfterMs: 1500 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wrap a downstream store failure while preserving the cause chain — the
|
||||
host's OTel span-event adapter walks `.cause` to attach both error
|
||||
fingerprints to the failed-request span:
|
||||
|
||||
```ts
|
||||
import { RATELIMIT_CODES, RateLimitError } from "@suaveplan/api-rate-limit/error-codes";
|
||||
|
||||
try {
|
||||
await store.take(key, 1);
|
||||
} catch (cause) {
|
||||
throw new RateLimitError(
|
||||
RATELIMIT_CODES.STORE_UNAVAILABLE,
|
||||
"rate-limit store unreachable",
|
||||
{ cause: cause as Error, context: { key } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RATELIMIT_CODES` — frozen registry, keys: `EXCEEDED`,
|
||||
`STORE_UNAVAILABLE`.
|
||||
- `RateLimitCode` — union of the two code string literals.
|
||||
- `RateLimitError` — extends `SuaveplanError<RateLimitCode>`; public
|
||||
constructor `(code, message, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` runs once at module load. The package's `src/index.ts`
|
||||
side-effect-imports this module so consumers of any subpath register
|
||||
the codes into the global dictionary. The public constructor is
|
||||
required by `scripts/lint/check-error-subclass-constructors.ts` and
|
||||
matches the same shape used by every other `*-Error` subclass in the
|
||||
project (e.g. `RouterInMemoryError`).
|
||||
@@ -0,0 +1,73 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the rate-limit OTel instrument registry. Today the package emits
|
||||
exactly one counter — `api.rate_limit.exceeded_total{plugin, route,
|
||||
key_kind}` — which the design doc §12.4 lists as the canonical
|
||||
rate-limit denial signal. The factory wraps the OTel global
|
||||
meter-provider lookup so production gets real export when an SDK is
|
||||
installed and tests get a no-op when one is not.
|
||||
|
||||
## Features
|
||||
|
||||
- `RATE_LIMIT_COUNTER_NAMES` frozen tuple — drives both factory
|
||||
construction AND the conformance test that asserts each name is
|
||||
present on the returned bundle.
|
||||
- `createRateLimitInstruments({ meterProvider? })` — explicit override
|
||||
precedence beats the global, so the middleware tests can assert
|
||||
exact `.add` call counts against an injected fake provider.
|
||||
- Returned object is `Object.freeze`-d so accidental mutation surfaces
|
||||
immediately rather than silently breaking downstream label
|
||||
invariants.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRateLimitInstruments } from "@suaveplan/api-rate-limit/instruments";
|
||||
|
||||
const instruments = createRateLimitInstruments();
|
||||
instruments.counters["api.rate_limit.exceeded_total"].add(1, {
|
||||
plugin: "billing",
|
||||
route: "POST /invoices",
|
||||
key_kind: "principal",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a captured meter-provider to assert exactly-once denial
|
||||
increments under a deterministic clock:
|
||||
|
||||
```ts
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import { createRateLimitInstruments } from "@suaveplan/api-rate-limit/instruments";
|
||||
|
||||
declare const fakeProvider: MeterProvider; // captures every .add() call
|
||||
const instruments = createRateLimitInstruments({ meterProvider: fakeProvider });
|
||||
// drive the middleware against the fake clock, then assert
|
||||
// fakeProvider's recorded .add() invocations match the denial transitions
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const RATE_LIMIT_COUNTER_NAMES: readonly ["api.rate_limit.exceeded_total"]`
|
||||
- `type RateLimitCounterName = (typeof RATE_LIMIT_COUNTER_NAMES)[number]`
|
||||
- `interface RateLimitInstruments { counters: Readonly<Record<RateLimitCounterName, Counter>> }`
|
||||
- `interface RateLimitInstrumentsConfig { meterProvider?: MeterProvider }`
|
||||
- `function createRateLimitInstruments(config?: RateLimitInstrumentsConfig): RateLimitInstruments`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package deliberately does NOT install an SDK — that's an operator
|
||||
concern. When no SDK is registered, the OTel no-op meter-provider
|
||||
returns no-op counters whose `.add()` is a function-call no-op, so
|
||||
this module is hot-path safe even with default config. The host's
|
||||
own `HostInstruments` already declares the same counter name; both
|
||||
emit, both reach the same exporter — OTel aggregates correctly because
|
||||
the meter name is the only difference and the SDK merges series by
|
||||
name + label-set.
|
||||
@@ -0,0 +1,108 @@
|
||||
# middleware
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The package's centerpiece. `createRateLimitMiddleware` builds a
|
||||
per-request gate around the token-bucket store; the gate emits 429
|
||||
responses with `Retry-After`, increments the
|
||||
`api.rate_limit.exceeded_total` counter on every denied request, and
|
||||
projects itself onto the host's `RequestRateLimiter` shape so the
|
||||
synchronous `consume(key, weight)` call site in the host's request
|
||||
pipeline gets the same backing store + instruments without
|
||||
re-deriving the route policy.
|
||||
|
||||
## Features
|
||||
|
||||
- Route-declared budget `{ rpm, burst?, key? }` with Zod validation at
|
||||
the ingestion boundary. `burst` defaults to `max(1, ceil(rpm/60))`
|
||||
when omitted so a 1-rpm route still tolerates a single-request burst.
|
||||
- Host-level defaults (`hostDefaults.perTenant`, `hostDefaults.perIp`)
|
||||
applied when a route declares no `rateLimit`. Route-level
|
||||
declarations always win.
|
||||
- 429 response carries `Retry-After`, `X-RateLimit-Limit`,
|
||||
`X-RateLimit-Remaining: 0`. Allowed responses carry `X-RateLimit-*`
|
||||
hints without `Retry-After`.
|
||||
- OTel counter `api.rate_limit.exceeded_total` increments once per
|
||||
denied request, labelled `{ plugin, route, key_kind }`.
|
||||
- `asRequestRateLimiter({ plugin, route, option?, keyKind? })` —
|
||||
bridges into the host's `consume(key, weight) → boolean` contract so
|
||||
the host pipeline keeps the same backing store / instruments.
|
||||
- When no budget applies (no route option, no host default), the gate
|
||||
is a no-op so plugins that never opt in pay zero overhead.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
|
||||
|
||||
const mw = createRateLimitMiddleware();
|
||||
const decision = mw.check({
|
||||
plugin: "billing",
|
||||
route: "POST /invoices",
|
||||
option: { rpm: 60, burst: 5 },
|
||||
ctx: {
|
||||
tenantId: "t1",
|
||||
principal: { id: "alice" },
|
||||
ip: "10.0.0.1",
|
||||
request: new Request("https://h/invoices", { method: "POST" }),
|
||||
},
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
// Return new Response(null, { status: 429, headers: decision.headers })
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a host-level default for IP-keyed buckets and a custom selector
|
||||
on a specific route. Drive the bucket forward against a `FakeClock`:
|
||||
|
||||
```ts
|
||||
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
|
||||
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";
|
||||
|
||||
let now = 0;
|
||||
const clock = () => now;
|
||||
const store = createMemoryRateLimitStore({ clock });
|
||||
const mw = createRateLimitMiddleware({
|
||||
store,
|
||||
clock,
|
||||
hostDefaults: { perIp: { rpm: 30, burst: 3 } },
|
||||
});
|
||||
|
||||
const limiter = mw.asRequestRateLimiter({
|
||||
plugin: "billing",
|
||||
route: "POST /invoices",
|
||||
option: { rpm: 60, burst: 5 },
|
||||
keyKind: "principal",
|
||||
});
|
||||
limiter.consume("principal:alice", 1); // → true, drains 1 token
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RateLimitRouteOption { rpm: number; burst?: number; key?: RateLimitKeySelector }`
|
||||
- `const RateLimitRouteOptionSchema` — Zod schema, runs at the ingestion boundary.
|
||||
- `interface HostRateLimitDefaults { perTenant?: RateLimitBudget; perIp?: RateLimitBudget }`
|
||||
- `interface CreateRateLimitMiddlewareOptions { store?; instruments?; clock?; hostDefaults? }`
|
||||
- `interface RateLimitDecision { allowed; status; retryAfterSeconds; headers; key; keyKind }`
|
||||
- `interface RequestRateLimiter { consume(key, weight): boolean }`
|
||||
- `interface RateLimitMiddleware { check; asRequestRateLimiter; store; instruments }`
|
||||
- `function createRateLimitMiddleware(options?: CreateRateLimitMiddlewareOptions): RateLimitMiddleware`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Zod schema runs once per route-option ingestion (cheap, not per
|
||||
request) — the `check` hot path consults the parsed shape directly.
|
||||
The decision returns a frozen header bag so downstream layers cannot
|
||||
mutate it unintentionally. The `key_kind` attribute is bounded to the
|
||||
four-value union from `ResolvedKey.kind` so OTel cardinality stays
|
||||
bounded even for a custom selector. The `asRequestRateLimiter`
|
||||
projection captures route policy at construction time because the
|
||||
host's synchronous `consume(key, weight)` shape has no slot for it;
|
||||
this keeps the host's per-request hot path single-load instead of
|
||||
re-resolving the route policy on every call.
|
||||
@@ -0,0 +1,85 @@
|
||||
# selector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolves the rate-limit bucket key for a per-request frame, given the
|
||||
route-declared (or host-defaulted) `RateLimitKeySelector`. The
|
||||
selector kind controls how the middleware fans the per-route budget
|
||||
across distinct callers — `"tenant"` shares the bucket across every
|
||||
principal in a tenant, `"principal"` isolates by authenticated user,
|
||||
`"ip"` falls back to the network identity, and the `"custom"` form
|
||||
gives the route author a free-form projection.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated union `RateLimitKeySelector =
|
||||
"tenant" | "principal" | "ip" | { kind: "custom"; select }`.
|
||||
- Default selection (`selector === undefined`) prefers
|
||||
`"principal"` when authenticated, else `"ip"` — matching design doc
|
||||
§6 step 4a.
|
||||
- Strict-mode fallback to the literal `"anon"` when the chosen kind's
|
||||
source is `null` (no tenant, no principal, no IP). The middleware
|
||||
never crashes; the limit just shares a single bucket across every
|
||||
truly-anonymous caller.
|
||||
- Returned `ResolvedKey` carries both the bucket key string AND the
|
||||
`kind` label so the OTel counter can attach a stable
|
||||
`key_kind` attribute without re-parsing the key.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveRateLimitKey } from "@suaveplan/api-rate-limit/selector";
|
||||
|
||||
const { key, kind } = resolveRateLimitKey(
|
||||
{ tenantId: "t1", principal: { id: "alice" }, ip: "10.0.0.1", request: new Request("https://h/") },
|
||||
"principal"
|
||||
);
|
||||
// → { key: "principal:alice", kind: "principal" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A custom selector that buckets per `(tenant, route-action-token)` so
|
||||
two different mutations on the same route share fan-in but distinct
|
||||
buckets:
|
||||
|
||||
```ts
|
||||
import { resolveRateLimitKey, type RateLimitKeySelector } from "@suaveplan/api-rate-limit/selector";
|
||||
|
||||
const custom: RateLimitKeySelector = {
|
||||
kind: "custom",
|
||||
select: (ctx) => {
|
||||
const u = new URL(ctx.request.url);
|
||||
const action = u.searchParams.get("action") ?? "default";
|
||||
return `${ctx.tenantId ?? "anon"}:${action}`;
|
||||
},
|
||||
};
|
||||
const r = resolveRateLimitKey(
|
||||
{ tenantId: "t1", principal: null, ip: "10.0.0.1", request: new Request("https://h/q?action=write") },
|
||||
custom
|
||||
);
|
||||
// → { key: "t1:write", kind: "custom" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type RateLimitKeySelector =
|
||||
"tenant" | "principal" | "ip" |
|
||||
{ readonly kind: "custom"; readonly select: (ctx: RateLimitContext) => string }`
|
||||
- `interface RateLimitContext { tenantId: string | null; principal: { id: string } | null; ip: string | null; request: Request }`
|
||||
- `interface ResolvedKey { readonly key: string; readonly kind: "tenant" | "principal" | "ip" | "custom" }`
|
||||
- `function resolveRateLimitKey(ctx, selector?): ResolvedKey`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is pure — it does not consult environment, the clock, or
|
||||
the store. That makes it trivial to unit-test the default selector
|
||||
chain against a structural `RateLimitContext` literal without
|
||||
spinning up a full request pipeline. The custom selector kind is
|
||||
deliberately the only variant that takes a callback; everything else
|
||||
is data so the selector is plain JSON-serialisable for
|
||||
`host.json`-level overrides.
|
||||
@@ -0,0 +1,88 @@
|
||||
# memory-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the default in-memory implementation of `RateLimitStoreLike`.
|
||||
Backed by `TokenBucket` from `@suaveplan/rate-limiter`, the store
|
||||
provisions a bucket per unique key on first contact, refills it at
|
||||
`budget.rpm / 60` tokens per second up to `budget.burst`, and evicts
|
||||
idle buckets via a TTL sweep plus a hard `maxKeys` ceiling so an
|
||||
adversarial caller cannot exhaust process memory by synthesising
|
||||
unbounded keys.
|
||||
|
||||
## Features
|
||||
|
||||
- `clock: Clock` injection (defaults to `Date.now`) so tests can drive
|
||||
the bucket forward deterministically against a `FakeClock`.
|
||||
- TTL eviction — buckets untouched beyond `ttlMs` (default 10 min) are
|
||||
removed by `sweep()`; the next `take` for that key provisions a
|
||||
fresh bucket at full capacity.
|
||||
- `maxKeys` cap (default 100_000) — when the cap is reached the next
|
||||
`take` first calls `sweep()`, and if that does not free a slot
|
||||
evicts the oldest entry.
|
||||
- Per-key bucket identity is `(key, budget)` — changing the budget on
|
||||
the same key resets the bucket so a route reconfigured at runtime
|
||||
does not inherit a stale capacity.
|
||||
- Invalid budgets (`rpm <= 0` or `burst <= 0`) throw
|
||||
`RateLimitError(STORE_UNAVAILABLE)` rather than silently allowing
|
||||
every request through.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";
|
||||
|
||||
const store = createMemoryRateLimitStore();
|
||||
const r = store.take("principal:alice", { rpm: 60, burst: 5 }, 1);
|
||||
if (!r.allowed) {
|
||||
// 429 + Retry-After: Math.ceil(r.retryAfterMs / 1000)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a deterministic `FakeClock` for tests, drive the bucket forward
|
||||
manually, and sweep when the test asserts eviction:
|
||||
|
||||
```ts
|
||||
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";
|
||||
|
||||
let now = 1_000;
|
||||
const store = createMemoryRateLimitStore({
|
||||
clock: () => now,
|
||||
ttlMs: 60_000,
|
||||
maxKeys: 10,
|
||||
});
|
||||
|
||||
store.take("k", { rpm: 60, burst: 5 }, 1); // allowed
|
||||
now += 120_000; // advance past ttlMs
|
||||
store.sweep(now);
|
||||
// the next take for "k" provisions a fresh bucket
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MemoryRateLimitStore implements RateLimitStoreLike`
|
||||
- constructor `(options?: MemoryStoreOptions)`
|
||||
- `take(key, budget, weight): RateLimitTakeResult`
|
||||
- `evict(key): void` — idempotent
|
||||
- `sweep(now?): void`
|
||||
- `get size(): number`
|
||||
- `interface MemoryStoreOptions { clock?: Clock; ttlMs?: number; maxKeys?: number }`
|
||||
- `function createMemoryRateLimitStore(options?): MemoryRateLimitStore`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TokenBucket.consume` already performs lazy refill against the injected
|
||||
clock, so the memory store does not duplicate refill arithmetic. The
|
||||
`retryAfter` field returned by the bucket is in milliseconds; the
|
||||
store forwards it verbatim as `retryAfterMs` (clamped to a minimum of
|
||||
`1` so denied requests never report a sub-millisecond retry window).
|
||||
Eviction is O(n) in the bucket count on the rare full-cap path; for
|
||||
the documented `maxKeys` of 100k that is well under 1ms on any
|
||||
realistic host. The Map iteration order is insertion-order per ES2015,
|
||||
which makes the `evictOldest` traversal deterministic.
|
||||
@@ -0,0 +1,83 @@
|
||||
# store-like
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `RateLimitStoreLike` interface — the storage abstraction
|
||||
behind the per-route token-bucket middleware. Keeping the interface
|
||||
type-only (zero runtime) makes it trivial to swap the default
|
||||
in-memory store for a Redis-backed implementation in a later change
|
||||
without ripping through call sites that already depend on the
|
||||
`RateLimitStoreLike` shape.
|
||||
|
||||
## Features
|
||||
|
||||
- `RateLimitBudget` (`rpm`, `burst`) — the per-bucket policy passed
|
||||
alongside every `take` call so the store can lazily provision a
|
||||
fresh bucket on first contact.
|
||||
- `RateLimitTakeResult` (`allowed`, `remaining`, `retryAfterMs`) — the
|
||||
return shape the middleware translates into a 429 response with
|
||||
`Retry-After` set to `Math.ceil(retryAfterMs / 1000)` on denial.
|
||||
- `take(key, budget, weight)` — synchronous to match the pipeline
|
||||
`RequestRateLimiter` contract. Async backends can wrap their I/O in
|
||||
a request-scoped snapshot.
|
||||
- `evict(key)` — idempotent eviction hook used by the TTL sweeper.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { RateLimitStoreLike } from "@suaveplan/api-rate-limit/store";
|
||||
|
||||
declare const store: RateLimitStoreLike;
|
||||
const result = store.take("principal:alice", { rpm: 60, burst: 5 }, 1);
|
||||
if (!result.allowed) {
|
||||
// 429 + Retry-After: Math.ceil(result.retryAfterMs / 1000)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A consumer-supplied Redis adapter satisfies the same interface — it
|
||||
just wraps an inline Lua script in a synchronous wrapper around the
|
||||
single-RTT redis-cluster call:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RateLimitBudget,
|
||||
RateLimitStoreLike,
|
||||
RateLimitTakeResult,
|
||||
} from "@suaveplan/api-rate-limit/store";
|
||||
|
||||
class RedisStore implements RateLimitStoreLike {
|
||||
take(key: string, budget: RateLimitBudget, weight: number): RateLimitTakeResult {
|
||||
// Pre-fetched Lua reply lives on a request-scoped snapshot:
|
||||
const reply = redisLuaTake(key, budget, weight);
|
||||
return {
|
||||
allowed: reply.allowed === 1,
|
||||
remaining: reply.remaining,
|
||||
retryAfterMs: reply.retryAfterMs,
|
||||
};
|
||||
}
|
||||
evict(key: string): void {
|
||||
redisDel(key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RateLimitBudget { readonly rpm: number; readonly burst: number }`
|
||||
- `interface RateLimitTakeResult { readonly allowed: boolean; readonly remaining: number; readonly retryAfterMs: number }`
|
||||
- `interface RateLimitStoreLike { take(key, budget, weight): RateLimitTakeResult; evict(key): void }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface is intentionally minimal — no `peek`, no `reset`. Both
|
||||
are derivable from `take(weight=0)` and `evict` respectively and would
|
||||
just widen the surface every adapter has to maintain. The
|
||||
`RateLimitTakeResult.retryAfterMs` field is measured in milliseconds
|
||||
internally so adapter writers do not lose precision; the middleware
|
||||
truncates to seconds when serialising the HTTP header.
|
||||
Reference in New Issue
Block a user