Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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).

View File

@@ -0,0 +1,93 @@
{
"name": "@suaveplan/api-rate-limit",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-8 per-route / per-principal / per-tenant / per-IP token-bucket middleware for the @suaveplan/api plugin foundation. 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./middleware": {
"import": {
"types": "./dist/middleware/index.d.ts",
"default": "./dist/middleware/index.js"
}
},
"./selector": {
"import": {
"types": "./dist/selector/index.d.ts",
"default": "./dist/selector/index.js"
}
},
"./store": {
"import": {
"types": "./dist/store/index.d.ts",
"default": "./dist/store/index.js"
}
},
"./instruments": {
"import": {
"types": "./dist/instruments/index.d.ts",
"default": "./dist/instruments/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/rate-limiter": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -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`).

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RATELIMIT_CODES, RateLimitError } from "./error-codes.js";
describe("error-codes", () => {
it("registers both codes under RATELIMIT", { timeout: 5000 }, () => {
expect(RATELIMIT_CODES.EXCEEDED).toBe("RATELIMIT.EXCEEDED");
expect(RATELIMIT_CODES.STORE_UNAVAILABLE).toBe(
"RATELIMIT.STORE_UNAVAILABLE"
);
});
it("constructs RateLimitError with code, message, context, cause", {
timeout: 5000,
}, () => {
const cause = new Error("downstream");
const err = new RateLimitError(RATELIMIT_CODES.EXCEEDED, "denied", {
cause,
context: { key: "principal:alice" },
});
expect(err).toBeInstanceOf(RateLimitError);
expect(err.name).toBe("RateLimitError");
expect(err.code).toBe("RATELIMIT.EXCEEDED");
expect(err.message).toBe("denied");
expect(err.cause).toBe(cause);
expect((err.context as { key: string }).key).toBe("principal:alice");
});
it("constructs without optional fields", { timeout: 5000 }, () => {
const err = new RateLimitError(
RATELIMIT_CODES.STORE_UNAVAILABLE,
"boom"
);
expect(err.code).toBe("RATELIMIT.STORE_UNAVAILABLE");
expect(err.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,40 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-rate-limit`.
*
* Codes follow the `RATELIMIT.<DOMAIN>` namespace as resolved by
* `@suaveplan/error`'s `defineCodes`. Every code MUST be thrown by a
* production path in this package — the conformance gate enumerates the
* registry and asserts each entry is referenced from at least one
* non-test source file.
*/
export const RATELIMIT_CODES = defineCodes("RATELIMIT", [
"EXCEEDED",
"STORE_UNAVAILABLE",
] as const);
/** Union of every code resolved through {@link RATELIMIT_CODES}. */
export type RateLimitCode =
(typeof RATELIMIT_CODES)[keyof typeof RATELIMIT_CODES];
/**
* Typed error for every `@suaveplan/api-rate-limit` failure.
*
* Extends `SuaveplanError` so callers can discriminate via
* `error instanceof RateLimitError` or via the stable `error.code`
* property. The base class declares a `protected` constructor; the
* `public` constructor here is the canonical entry point for
* synchronous `throw new RateLimitError(...)` call sites and is
* required by `scripts/lint/check-error-subclass-constructors.ts`.
*/
export class RateLimitError extends SuaveplanError<RateLimitCode> {
public constructor(
code: RateLimitCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "RateLimitError";
}
}

View File

@@ -0,0 +1,2 @@
export type { RateLimitCode } from "./error-codes.js";
export { RATELIMIT_CODES, RateLimitError } from "./error-codes.js";

View File

@@ -0,0 +1,55 @@
// Side-effect import to register codes in the global dictionary.
import "./error-codes/error-codes.js";
// Error codes
export type { RateLimitCode } from "./error-codes/error-codes.js";
export {
RATELIMIT_CODES,
RateLimitError,
} from "./error-codes/error-codes.js";
// Instruments
export type {
RateLimitCounterName,
RateLimitInstruments,
RateLimitInstrumentsConfig,
} from "./instruments/instruments.js";
export {
createRateLimitInstruments,
RATE_LIMIT_COUNTER_NAMES,
} from "./instruments/instruments.js";
// Middleware
export type {
CreateRateLimitMiddlewareOptions,
HostRateLimitDefaults,
RateLimitDecision,
RateLimitInvocation,
RateLimitMiddleware,
RateLimitRouteOption,
RequestRateLimiter,
} from "./middleware/middleware.js";
export {
createRateLimitMiddleware,
RateLimitRouteOptionSchema,
} from "./middleware/middleware.js";
// Selector
export type {
RateLimitContext,
RateLimitKeySelector,
ResolvedKey,
} from "./selector/selector.js";
export { resolveRateLimitKey } from "./selector/selector.js";
// Store
export type { MemoryStoreOptions } from "./store/memory-store/memory-store.js";
export {
createMemoryRateLimitStore,
MemoryRateLimitStore,
} from "./store/memory-store/memory-store.js";
export type {
RateLimitBudget,
RateLimitStoreLike,
RateLimitTakeResult,
} from "./store/store-like/store-like.js";

View File

@@ -0,0 +1,9 @@
export type {
RateLimitCounterName,
RateLimitInstruments,
RateLimitInstrumentsConfig,
} from "./instruments.js";
export {
createRateLimitInstruments,
RATE_LIMIT_COUNTER_NAMES,
} from "./instruments.js";

View File

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

View File

@@ -0,0 +1,99 @@
import type { Meter, MeterProvider } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createRateLimitInstruments,
RATE_LIMIT_COUNTER_NAMES,
} from "./instruments.js";
interface RecordedCounter {
readonly name: string;
readonly addCalls: {
value: number;
attributes?: Readonly<Record<string, unknown>>;
}[];
}
function fakeProvider(): {
provider: MeterProvider;
counters: RecordedCounter[];
} {
const counters: RecordedCounter[] = [];
const provider: MeterProvider = {
getMeter(_name: string): Meter {
const meter: Meter = {
createCounter(n: string) {
const r: RecordedCounter = { name: n, addCalls: [] };
counters.push(r);
return {
add(
v: number,
attrs?: Readonly<Record<string, unknown>>
) {
r.addCalls.push({ value: v, attributes: attrs });
},
} as ReturnType<Meter["createCounter"]>;
},
createUpDownCounter() {
throw new Error("not used");
},
createHistogram() {
throw new Error("not used");
},
createObservableCounter() {
throw new Error("not used");
},
createObservableGauge() {
throw new Error("not used");
},
createObservableUpDownCounter() {
throw new Error("not used");
},
addBatchObservableCallback() {
/* noop */
},
removeBatchObservableCallback() {
/* noop */
},
} as Meter;
return meter;
},
};
return { provider, counters };
}
describe("instruments", () => {
it("creates the rate-limit exceeded counter", { timeout: 5000 }, () => {
const { provider, counters } = fakeProvider();
const inst = createRateLimitInstruments({ meterProvider: provider });
expect(counters.map((c) => c.name)).toEqual([
"api.rate_limit.exceeded_total",
]);
inst.counters["api.rate_limit.exceeded_total"].add(1, {
plugin: "p",
route: "GET /",
key_kind: "principal",
});
expect(counters[0]!.addCalls).toHaveLength(1);
expect(counters[0]!.addCalls[0]!.attributes).toEqual({
plugin: "p",
route: "GET /",
key_kind: "principal",
});
});
it("falls back to global meter-provider when none supplied", {
timeout: 5000,
}, () => {
const inst = createRateLimitInstruments();
// Global no-op provider returns no-op counters; .add must not throw.
expect(() =>
inst.counters["api.rate_limit.exceeded_total"].add(1, {})
).not.toThrow();
});
it("exposes the full counter-name tuple", { timeout: 5000 }, () => {
expect(RATE_LIMIT_COUNTER_NAMES).toEqual([
"api.rate_limit.exceeded_total",
]);
});
});

View File

@@ -0,0 +1,44 @@
import type { MeterProvider, Counter as OtelCounter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Counter names emitted by this package on production paths. Mirrors
* the design-doc §12.4 entry exactly so the host's metrics inventory
* stays a single source of truth.
*/
export const RATE_LIMIT_COUNTER_NAMES = [
"api.rate_limit.exceeded_total",
] as const;
export type RateLimitCounterName = (typeof RATE_LIMIT_COUNTER_NAMES)[number];
/** Frozen instrument bundle the middleware records denials against. */
export interface RateLimitInstruments {
readonly counters: Readonly<Record<RateLimitCounterName, OtelCounter>>;
}
/** Configuration for {@link createRateLimitInstruments}. */
export interface RateLimitInstrumentsConfig {
/** Optional OTel meter-provider override; defaults to the global one. */
readonly meterProvider?: MeterProvider;
}
/**
* Build the rate-limit instrument registry. The meter is named
* `@suaveplan/api-rate-limit` so exported metrics are unambiguous in
* dashboards. When no SDK is installed the OTel no-op provider
* silently swallows the `.add()` calls.
*/
export function createRateLimitInstruments(
config: RateLimitInstrumentsConfig = {}
): RateLimitInstruments {
const provider = config.meterProvider ?? metrics.getMeterProvider();
const meter = provider.getMeter("@suaveplan/api-rate-limit");
const counters = Object.create(null) as Record<
RateLimitCounterName,
OtelCounter
>;
for (const name of RATE_LIMIT_COUNTER_NAMES) {
counters[name] = meter.createCounter(name);
}
return Object.freeze({ counters: Object.freeze(counters) });
}

View File

@@ -0,0 +1,13 @@
export type {
CreateRateLimitMiddlewareOptions,
HostRateLimitDefaults,
RateLimitDecision,
RateLimitInvocation,
RateLimitMiddleware,
RateLimitRouteOption,
RequestRateLimiter,
} from "./middleware.js";
export {
createRateLimitMiddleware,
RateLimitRouteOptionSchema,
} from "./middleware.js";

View File

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

View File

@@ -0,0 +1,98 @@
import type { Meter, MeterProvider } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRateLimitInstruments } from "../instruments/instruments.js";
import type { RateLimitContext } from "../selector/selector.js";
import { createRateLimitMiddleware } from "./middleware.js";
function buildCtx(): RateLimitContext {
return {
tenantId: "t1",
principal: { id: "alice" },
ip: "10.0.0.1",
request: new Request("https://h/r"),
};
}
interface RecordedAdd {
value: number;
attributes?: Readonly<Record<string, unknown>>;
}
function captureProvider(): { provider: MeterProvider; adds: RecordedAdd[] } {
const adds: RecordedAdd[] = [];
const provider: MeterProvider = {
getMeter(_name: string): Meter {
return {
createCounter(_n: string) {
return {
add(
v: number,
attrs?: Readonly<Record<string, unknown>>
) {
adds.push({ value: v, attributes: attrs });
},
} as ReturnType<Meter["createCounter"]>;
},
} as Meter;
},
};
return { provider, adds };
}
describe("spec-conformance: api.rate_limit.exceeded_total", () => {
it("denial increments api.rate_limit.exceeded_total exactly once per denied request", {
timeout: 5000,
}, () => {
const now = 0;
const { provider, adds } = captureProvider();
const mw = createRateLimitMiddleware({
clock: () => now,
instruments: createRateLimitInstruments({
meterProvider: provider,
}),
});
const option = { rpm: 60, burst: 1 } as const;
// First request allowed
mw.check({ plugin: "p", route: "POST /r", option, ctx: buildCtx() });
// Next 10 requests all denied within the same window
for (let i = 0; i < 10; i++) {
const d = mw.check({
plugin: "p",
route: "POST /r",
option,
ctx: buildCtx(),
});
expect(d.allowed).toBe(false);
}
// Counter incremented exactly 10 times — once per denied request
expect(adds).toHaveLength(10);
for (const a of adds) {
expect(a.value).toBe(1);
expect(a.attributes).toEqual({
plugin: "p",
route: "POST /r",
key_kind: "principal",
});
}
});
it("Retry-After header matches the bucket refill estimate", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({ clock: () => now });
const option = { rpm: 60, burst: 1 } as const;
mw.check({ plugin: "p", route: "POST /r", option, ctx: buildCtx() });
const denied = mw.check({
plugin: "p",
route: "POST /r",
option,
ctx: buildCtx(),
});
expect(denied.allowed).toBe(false);
const ra = Number(denied.headers["Retry-After"]);
expect(ra).toBeGreaterThanOrEqual(1);
// Token-bucket at rpm 60 refills 1 token per second → Retry-After
// should round up to 1 second when 1 token is needed.
expect(ra).toBe(1);
});
});

View File

@@ -0,0 +1,482 @@
import type { Meter, MeterProvider } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RateLimitError } from "../error-codes/error-codes.js";
import { createRateLimitInstruments } from "../instruments/instruments.js";
import type { RateLimitContext } from "../selector/selector.js";
import { createMemoryRateLimitStore } from "../store/memory-store/memory-store.js";
import {
createRateLimitMiddleware,
RateLimitRouteOptionSchema,
} from "./middleware.js";
function buildCtx(o: Partial<RateLimitContext> = {}): RateLimitContext {
return {
tenantId: "tenantId" in o ? o.tenantId! : "t1",
principal: "principal" in o ? o.principal! : { id: "alice" },
ip: "ip" in o ? o.ip! : "10.0.0.1",
request: o.request ?? new Request("https://h/r"),
};
}
interface RecordedAdd {
value: number;
attributes?: Readonly<Record<string, unknown>>;
}
function captureProvider(): {
provider: MeterProvider;
adds: RecordedAdd[];
} {
const adds: RecordedAdd[] = [];
const provider: MeterProvider = {
getMeter(_name: string): Meter {
return {
createCounter(_n: string) {
return {
add(
v: number,
attrs?: Readonly<Record<string, unknown>>
) {
adds.push({ value: v, attributes: attrs });
},
} as ReturnType<Meter["createCounter"]>;
},
} as Meter;
},
};
return { provider, adds };
}
describe("createRateLimitMiddleware", () => {
it("burst window allows N requests", { timeout: 5000 }, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
});
const option = { rpm: 60, burst: 5 } as const;
for (let i = 0; i < 5; i++) {
const d = mw.check({
plugin: "p",
route: "POST /r",
option,
ctx: buildCtx(),
});
expect(d.allowed).toBe(true);
expect(d.status).toBe(200);
expect(d.headers["X-RateLimit-Limit"]).toBe("60");
}
});
it("sixth request in burst window is rejected with Retry-After", {
timeout: 5000,
}, () => {
const now = 0;
const { provider, adds } = captureProvider();
const mw = createRateLimitMiddleware({
clock: () => now,
instruments: createRateLimitInstruments({
meterProvider: provider,
}),
});
const option = { rpm: 60, burst: 5 } as const;
for (let i = 0; i < 5; i++) {
mw.check({
plugin: "p",
route: "POST /r",
option,
ctx: buildCtx(),
});
}
const d = mw.check({
plugin: "p",
route: "POST /r",
option,
ctx: buildCtx(),
});
expect(d.allowed).toBe(false);
expect(d.status).toBe(429);
expect(d.retryAfterSeconds).toBeGreaterThanOrEqual(1);
expect(d.headers["Retry-After"]).toBe(String(d.retryAfterSeconds));
expect(d.headers["X-RateLimit-Remaining"]).toBe("0");
expect(d.error).toBeInstanceOf(RateLimitError);
expect(d.error?.code).toBe("RATELIMIT.EXCEEDED");
expect(adds).toHaveLength(1);
expect(adds[0]).toEqual({
value: 1,
attributes: {
plugin: "p",
route: "POST /r",
key_kind: "principal",
},
});
});
it("clock advance refills tokens deterministically", {
timeout: 5000,
}, () => {
let now = 0;
const mw = createRateLimitMiddleware({ clock: () => now });
const option = { rpm: 60, burst: 1 } as const;
expect(
mw.check({
plugin: "p",
route: "GET /",
option,
ctx: buildCtx(),
}).allowed
).toBe(true);
expect(
mw.check({
plugin: "p",
route: "GET /",
option,
ctx: buildCtx(),
}).allowed
).toBe(false);
now += 1_000;
expect(
mw.check({
plugin: "p",
route: "GET /",
option,
ctx: buildCtx(),
}).allowed
).toBe(true);
});
it("route override beats host default", { timeout: 5000 }, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: {
perTenant: { rpm: 100, burst: 100 },
perIp: { rpm: 100, burst: 100 },
},
});
const option = { rpm: 10, burst: 1, key: "ip" as const };
const ctx = buildCtx();
expect(
mw.check({ plugin: "p", route: "GET /", option, ctx }).allowed
).toBe(true);
expect(
mw.check({ plugin: "p", route: "GET /", option, ctx }).allowed
).toBe(false);
});
it("no-op when neither route nor host declares", { timeout: 5000 }, () => {
const mw = createRateLimitMiddleware();
for (let i = 0; i < 100; i++) {
const d = mw.check({
plugin: "p",
route: "GET /",
ctx: buildCtx(),
});
expect(d.allowed).toBe(true);
expect(d.status).toBe(200);
}
});
it("host perTenant default applies when key kind is tenant", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: { perTenant: { rpm: 60, burst: 1 } },
});
// Use a route option that selects tenant key but has no rpm —
// simulated by providing no option and relying on default... but
// default key is principal/ip. We instead pass option that picks
// tenant key kind via an explicit option carrying just rpm/burst.
// Cleaner: pass option with key: tenant; that sets keyKind tenant
// and overrides budget. To test host default, drop the option
// entirely but use a custom selector via host defaults isn't
// supported — so route SELECTOR must be supplied separately.
// Here we model: a route with key=tenant but NO rpm declared is
// not allowed by schema. Real-world host wiring resolves key kind
// BEFORE the option. So just test by providing option with key
// tenant and asserting host default fallback is not used:
const ctx = buildCtx({ tenantId: "tA" });
// No option: keyKind is "principal" (principal present) →
// host default perTenant does NOT apply → no-op.
expect(mw.check({ plugin: "p", route: "GET /", ctx }).allowed).toBe(
true
);
// Now without principal, keyKind defaults to "ip" → still no
// perIp default → no-op.
expect(
mw.check({
plugin: "p",
route: "GET /",
ctx: buildCtx({ principal: null }),
}).allowed
).toBe(true);
});
it("host perTenant default applies via hostKeyKind override", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: { perTenant: { rpm: 60, burst: 1 } },
});
const ctx = buildCtx({ tenantId: "tA" });
const first = mw.check({
plugin: "p",
route: "GET /",
ctx,
hostKeyKind: "tenant",
});
expect(first.allowed).toBe(true);
expect(first.keyKind).toBe("tenant");
const second = mw.check({
plugin: "p",
route: "GET /",
ctx,
hostKeyKind: "tenant",
});
expect(second.allowed).toBe(false);
});
it("host perIp default applies via hostKeyKind override", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: { perIp: { rpm: 60, burst: 1 } },
});
const ctx = buildCtx();
const first = mw.check({
plugin: "p",
route: "GET /",
ctx,
hostKeyKind: "ip",
});
expect(first.allowed).toBe(true);
const second = mw.check({
plugin: "p",
route: "GET /",
ctx,
hostKeyKind: "ip",
});
expect(second.allowed).toBe(false);
});
it("host perIp default applies when no principal", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: { perIp: { rpm: 60, burst: 1 } },
});
const ctx = buildCtx({ principal: null });
expect(mw.check({ plugin: "p", route: "GET /", ctx }).allowed).toBe(
true
);
expect(mw.check({ plugin: "p", route: "GET /", ctx }).allowed).toBe(
false
);
});
it("rejects malformed route options via Zod", {
timeout: 5000,
}, () => {
const mw = createRateLimitMiddleware();
expect(() =>
mw.check({
plugin: "p",
route: "GET /",
option: { rpm: -1 } as never,
ctx: buildCtx(),
})
).toThrow();
// Schema parses good shapes
expect(RateLimitRouteOptionSchema.safeParse({ rpm: 10 }).success).toBe(
true
);
expect(
RateLimitRouteOptionSchema.safeParse({
rpm: 10,
burst: 2,
key: "tenant",
}).success
).toBe(true);
});
it("default burst when omitted", { timeout: 5000 }, () => {
const now = 0;
const mw = createRateLimitMiddleware({ clock: () => now });
// rpm 60, no burst → burst defaults to 1
const option = { rpm: 60 } as const;
const d1 = mw.check({
plugin: "p",
route: "GET /",
option,
ctx: buildCtx(),
});
expect(d1.allowed).toBe(true);
const d2 = mw.check({
plugin: "p",
route: "GET /",
option,
ctx: buildCtx(),
});
expect(d2.allowed).toBe(false);
});
it("exposes store + instruments", { timeout: 5000 }, () => {
const mw = createRateLimitMiddleware();
expect(mw.store).toBeDefined();
expect(
mw.instruments.counters["api.rate_limit.exceeded_total"]
).toBeDefined();
});
it("uses default store when none supplied (no clock)", {
timeout: 5000,
}, () => {
const mw = createRateLimitMiddleware();
const d = mw.check({
plugin: "p",
route: "GET /",
option: { rpm: 60, burst: 2 },
ctx: buildCtx(),
});
expect(d.allowed).toBe(true);
});
});
describe("asRequestRateLimiter", () => {
it("returns true when no budget configured", { timeout: 5000 }, () => {
const mw = createRateLimitMiddleware();
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
});
expect(limiter.consume("k", 1)).toBe(true);
});
it("consumes against route option budget and increments counter on deny", {
timeout: 5000,
}, () => {
const { provider, adds } = captureProvider();
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
instruments: createRateLimitInstruments({
meterProvider: provider,
}),
});
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "POST /r",
option: { rpm: 60, burst: 1 },
keyKind: "principal",
});
expect(limiter.consume("principal:alice", 1)).toBe(true);
expect(limiter.consume("principal:alice", 1)).toBe(false);
expect(adds).toHaveLength(1);
expect(adds[0]?.attributes).toEqual({
plugin: "p",
route: "POST /r",
key_kind: "principal",
});
});
it("consumes against host perTenant default when keyKind=tenant", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: { perTenant: { rpm: 60, burst: 1 } },
});
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
keyKind: "tenant",
});
expect(limiter.consume("tenant:t1", 1)).toBe(true);
expect(limiter.consume("tenant:t1", 1)).toBe(false);
});
it("consumes against host perIp default when keyKind=ip", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({
clock: () => now,
hostDefaults: { perIp: { rpm: 60, burst: 1 } },
});
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
keyKind: "ip",
});
expect(limiter.consume("ip:1", 1)).toBe(true);
expect(limiter.consume("ip:1", 1)).toBe(false);
});
it("uses burst default when option omits it", {
timeout: 5000,
}, () => {
const now = 0;
const mw = createRateLimitMiddleware({ clock: () => now });
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
option: { rpm: 60 },
});
expect(limiter.consume("k", 1)).toBe(true);
expect(limiter.consume("k", 1)).toBe(false);
});
it("defaults keyKind to principal when omitted", {
timeout: 5000,
}, () => {
const mw = createRateLimitMiddleware();
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
option: { rpm: 60, burst: 1 },
});
expect(limiter.consume("k", 1)).toBe(true);
});
it("returns true on missing host default for custom keyKind", {
timeout: 5000,
}, () => {
const mw = createRateLimitMiddleware();
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
keyKind: "custom",
});
expect(limiter.consume("k", 1)).toBe(true);
});
it("returns true when keyKind=tenant but no perTenant default set", {
timeout: 5000,
}, () => {
const mw = createRateLimitMiddleware({
hostDefaults: { perIp: { rpm: 60, burst: 1 } },
});
const limiter = mw.asRequestRateLimiter({
plugin: "p",
route: "GET /",
keyKind: "tenant",
});
expect(limiter.consume("k", 1)).toBe(true);
});
});
describe("middleware uses supplied store", () => {
it("honours an explicit store instance", { timeout: 5000 }, () => {
const now = 0;
const store = createMemoryRateLimitStore({ clock: () => now });
const mw = createRateLimitMiddleware({ store, clock: () => now });
expect(mw.store).toBe(store);
});
});

View File

@@ -0,0 +1,272 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import { RATELIMIT_CODES, RateLimitError } from "../error-codes/error-codes.js";
import type { RateLimitInstruments } from "../instruments/instruments.js";
import { createRateLimitInstruments } from "../instruments/instruments.js";
import type {
RateLimitContext,
RateLimitKeySelector,
} from "../selector/selector.js";
import { resolveRateLimitKey } from "../selector/selector.js";
import { createMemoryRateLimitStore } from "../store/memory-store/memory-store.js";
import type {
RateLimitBudget,
RateLimitStoreLike,
} from "../store/store-like/store-like.js";
/**
* Per-route rate-limit declaration. A superset of the
* `PipelineRouteOptions.rateLimit` slot in `@suaveplan/api-types`; the
* host carries `{ rpm }` and this package widens with `burst` + `key`.
* When `burst` is omitted, it defaults to `Math.max(1, Math.ceil(rpm /
* 60))` so a 1-rpm route still allows a single-request burst.
*/
export const RateLimitRouteOptionSchema = z.object({
rpm: z.number().int().positive(),
burst: z.number().int().positive().optional(),
key: z
.union([z.literal("tenant"), z.literal("principal"), z.literal("ip")])
.optional(),
});
export interface RateLimitRouteOption {
readonly rpm: number;
readonly burst?: number;
readonly key?: RateLimitKeySelector;
}
/** Host-level defaults, applied when a route declares no `rateLimit`. */
export interface HostRateLimitDefaults {
/** Default applied when the resolved key kind is `"tenant"`. */
readonly perTenant?: RateLimitBudget;
/** Default applied when the resolved key kind is `"ip"`. */
readonly perIp?: RateLimitBudget;
}
/** Construction options for {@link createRateLimitMiddleware}. */
export interface CreateRateLimitMiddlewareOptions {
readonly store?: RateLimitStoreLike;
readonly instruments?: RateLimitInstruments;
readonly clock?: Clock;
readonly hostDefaults?: HostRateLimitDefaults;
}
/**
* Minimal `RequestRateLimiter` shape consumed by
* `@suaveplan/api-host`'s request pipeline. Declared locally as a
* structural type so this package does not need a runtime peer-dep on
* the host package.
*/
export interface RequestRateLimiter {
consume(key: string, weight: number): boolean;
}
/** Per-request frame the middleware consults. */
export interface RateLimitInvocation {
readonly plugin: string;
readonly route: string;
readonly option?: RateLimitRouteOption;
readonly ctx: RateLimitContext;
/**
* Optional host-supplied key-kind override. When set AND the route
* declares no `option`, the middleware uses this kind to drive both
* key resolution AND the matching `hostDefaults.perTenant` /
* `hostDefaults.perIp` lookup. Used by host adapters that wire a
* tenant-keyed default budget across a whole plugin family without
* making every route declare it.
*/
readonly hostKeyKind?: "tenant" | "ip";
}
/** Result of a single middleware check. */
export interface RateLimitDecision {
readonly allowed: boolean;
readonly status: 200 | 429;
readonly retryAfterSeconds: number;
readonly headers: Readonly<Record<string, string>>;
readonly key: string;
readonly keyKind: "tenant" | "principal" | "ip" | "custom";
/**
* Present only on a 429 denial. Carries the stable
* `RATELIMIT.EXCEEDED` code so callers translating the decision
* into an error envelope can attach the canonical fingerprint.
*/
readonly error?: RateLimitError;
}
/**
* Bundle returned by {@link createRateLimitMiddleware}. Exposes:
*
* - `check(invocation)` — the per-request gate the host pipeline calls
* between tenant resolution and RBAC. Returns a `RateLimitDecision`
* the host (or a Hono adapter) translates into a 429 response.
* - `asRequestRateLimiter(...)` — projection onto the
* `RequestRateLimiter` contract for the host's pipeline. The
* projection captures plugin / route / budget at construction time
* so the synchronous `consume(key, weight)` shape can apply the
* correct budget without re-resolving the route.
* - `store` / `instruments` — exposed for tests + admin inspection.
*/
export interface RateLimitMiddleware {
check(invocation: RateLimitInvocation): RateLimitDecision;
asRequestRateLimiter(args: {
plugin: string;
route: string;
option?: RateLimitRouteOption;
keyKind?: "tenant" | "principal" | "ip" | "custom";
}): RequestRateLimiter;
readonly store: RateLimitStoreLike;
readonly instruments: RateLimitInstruments;
}
const DEFAULT_BURST_FOR = (rpm: number): number =>
Math.max(1, Math.ceil(rpm / 60));
function resolveBudget(
invocation: RateLimitInvocation,
keyKind: RateLimitDecision["keyKind"],
hostDefaults: HostRateLimitDefaults | undefined
): RateLimitBudget | null {
const option = invocation.option;
if (option !== undefined) {
// Validate at ingestion boundary — discriminator + shape.
RateLimitRouteOptionSchema.parse(option);
return {
rpm: option.rpm,
burst: option.burst ?? DEFAULT_BURST_FOR(option.rpm),
};
}
if (hostDefaults !== undefined) {
if (keyKind === "tenant" && hostDefaults.perTenant !== undefined) {
return hostDefaults.perTenant;
}
if (keyKind === "ip" && hostDefaults.perIp !== undefined) {
return hostDefaults.perIp;
}
}
return null;
}
/**
* Build the rate-limit middleware. The default backing store is the
* in-memory implementation; production hosts can inject a Redis store
* (a later adapter package) by passing `options.store`.
*/
export function createRateLimitMiddleware(
options: CreateRateLimitMiddlewareOptions = {}
): RateLimitMiddleware {
const clock = options.clock;
const store =
options.store ?? createMemoryRateLimitStore(clock ? { clock } : {});
const instruments = options.instruments ?? createRateLimitInstruments();
const hostDefaults = options.hostDefaults;
const middleware: RateLimitMiddleware = {
store,
instruments,
check(invocation: RateLimitInvocation): RateLimitDecision {
// Route-declared key takes precedence; otherwise host override
// (`hostKeyKind`) drives both the selector and the matching
// host-default budget.
const selectorKind =
invocation.option?.key ?? invocation.hostKeyKind;
const { key, kind } = resolveRateLimitKey(
invocation.ctx,
selectorKind
);
const budget = resolveBudget(invocation, kind, hostDefaults);
if (budget === null) {
return {
allowed: true,
status: 200,
retryAfterSeconds: 0,
headers: Object.freeze({}),
key,
keyKind: kind,
};
}
const result = store.take(key, budget, 1);
if (result.allowed) {
return {
allowed: true,
status: 200,
retryAfterSeconds: 0,
headers: Object.freeze({
"X-RateLimit-Limit": String(budget.rpm),
"X-RateLimit-Remaining": String(result.remaining),
}),
key,
keyKind: kind,
};
}
const retryAfterSeconds = Math.max(
1,
Math.ceil(result.retryAfterMs / 1000)
);
instruments.counters["api.rate_limit.exceeded_total"].add(1, {
plugin: invocation.plugin,
route: invocation.route,
key_kind: kind,
});
const error = new RateLimitError(
RATELIMIT_CODES.EXCEEDED,
"rate-limit budget exhausted",
{
context: {
plugin: invocation.plugin,
route: invocation.route,
key,
keyKind: kind,
retryAfterSeconds,
rpm: budget.rpm,
burst: budget.burst,
},
}
);
return {
allowed: false,
status: 429,
retryAfterSeconds,
headers: Object.freeze({
"Retry-After": String(retryAfterSeconds),
"X-RateLimit-Limit": String(budget.rpm),
"X-RateLimit-Remaining": "0",
}),
key,
keyKind: kind,
error,
};
},
asRequestRateLimiter(args): RequestRateLimiter {
const option = args.option;
const keyKind = args.keyKind ?? "principal";
const budget = option
? {
rpm: option.rpm,
burst: option.burst ?? DEFAULT_BURST_FOR(option.rpm),
}
: keyKind === "tenant"
? hostDefaults?.perTenant
: keyKind === "ip"
? hostDefaults?.perIp
: undefined;
return {
consume(key: string, weight: number): boolean {
if (budget === undefined) return true;
const r = store.take(key, budget, weight);
if (!r.allowed) {
instruments.counters[
"api.rate_limit.exceeded_total"
].add(1, {
plugin: args.plugin,
route: args.route,
key_kind: keyKind,
});
}
return r.allowed;
},
};
},
};
return middleware;
}

View File

@@ -0,0 +1,6 @@
export type {
RateLimitContext,
RateLimitKeySelector,
ResolvedKey,
} from "./selector.js";
export { resolveRateLimitKey } from "./selector.js";

View File

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

View File

@@ -0,0 +1,101 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { RateLimitContext, RateLimitKeySelector } from "./selector.js";
import { resolveRateLimitKey } from "./selector.js";
function buildCtx(overrides: Partial<RateLimitContext> = {}): RateLimitContext {
return {
tenantId: "tenantId" in overrides ? overrides.tenantId! : "t1",
principal:
"principal" in overrides ? overrides.principal! : { id: "alice" },
ip: "ip" in overrides ? overrides.ip! : "10.0.0.1",
request: overrides.request ?? new Request("https://h/route"),
};
}
describe("resolveRateLimitKey", () => {
it("tenant key isolates buckets", { timeout: 5000 }, () => {
const a = resolveRateLimitKey(
buildCtx({ tenantId: "tenant-a" }),
"tenant"
);
const b = resolveRateLimitKey(
buildCtx({ tenantId: "tenant-b" }),
"tenant"
);
expect(a.key).toBe("tenant:tenant-a");
expect(b.key).toBe("tenant:tenant-b");
expect(a.kind).toBe("tenant");
expect(a.key).not.toBe(b.key);
});
it("principal key projects principal id", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(
buildCtx({ principal: { id: "bob" } }),
"principal"
);
expect(r).toEqual({ key: "principal:bob", kind: "principal" });
});
it("ip key projects ip", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(buildCtx({ ip: "192.0.2.10" }), "ip");
expect(r).toEqual({ key: "ip:192.0.2.10", kind: "ip" });
});
it("default falls back to IP without principal", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(
buildCtx({ principal: null, ip: "10.0.0.2" })
);
expect(r).toEqual({ key: "ip:10.0.0.2", kind: "ip" });
});
it("default uses principal when present", { timeout: 5000 }, () => {
const r = resolveRateLimitKey(buildCtx());
expect(r).toEqual({ key: "principal:alice", kind: "principal" });
});
it("strict-mode anon fallback when tenant/principal/ip missing", {
timeout: 5000,
}, () => {
expect(
resolveRateLimitKey(buildCtx({ tenantId: null }), "tenant")
).toEqual({ key: "tenant:anon", kind: "tenant" });
expect(
resolveRateLimitKey(buildCtx({ principal: null }), "principal")
).toEqual({ key: "principal:anon", kind: "principal" });
expect(resolveRateLimitKey(buildCtx({ ip: null }), "ip")).toEqual({
key: "ip:anon",
kind: "ip",
});
});
it("custom selector receives the request and produces a key", {
timeout: 5000,
}, () => {
const selector: RateLimitKeySelector = {
kind: "custom",
select: (c) => {
const u = new URL(c.request.url);
return `${c.tenantId}:${u.pathname}`;
},
};
const r = resolveRateLimitKey(
buildCtx({ request: new Request("https://h/foo") }),
selector
);
expect(r).toEqual({ key: "t1:/foo", kind: "custom" });
});
it("RateLimitKeySelector is the documented union", {
timeout: 5000,
}, () => {
expectTypeOf<RateLimitKeySelector>().toEqualTypeOf<
| "tenant"
| "principal"
| "ip"
| {
readonly kind: "custom";
readonly select: (ctx: RateLimitContext) => string;
}
>();
});
});

View File

@@ -0,0 +1,71 @@
/**
* Per-route key-selector resolution.
*
* The selector kind is part of the route's `rateLimit` option block
* (declared by the plugin author) plus an optional host-level default.
* Resolution happens once per request, before the middleware consults
* the store.
*/
/**
* Minimal subset of `RequestContext` the selector consults. Declared
* locally so this package does not need a runtime peer-dep on
* `@suaveplan/api-types/plugin-context`. Anything broader can be
* stripped at the call site.
*/
export interface RateLimitContext {
readonly tenantId: string | null;
readonly principal: { readonly id: string } | null;
readonly ip: string | null;
readonly request: Request;
}
/** Discriminated union of supported selector kinds. */
export type RateLimitKeySelector =
| "tenant"
| "principal"
| "ip"
| {
readonly kind: "custom";
readonly select: (ctx: RateLimitContext) => string;
};
/** Result of selector resolution. */
export interface ResolvedKey {
readonly key: string;
readonly kind: "tenant" | "principal" | "ip" | "custom";
}
/**
* Resolve the rate-limit key for a request. When `selector` is
* `undefined`, the default is `"principal"` if a principal is present
* else `"ip"`. Strict-mode: if even the IP fallback is unavailable
* (no `X-Forwarded-For`, no remote address), the key falls back to
* the literal string `"anon"` so the bucket logic never crashes.
*/
export function resolveRateLimitKey(
ctx: RateLimitContext,
selector?: RateLimitKeySelector
): ResolvedKey {
const effective: RateLimitKeySelector =
selector ?? (ctx.principal !== null ? "principal" : "ip");
if (typeof effective === "object") {
return { key: effective.select(ctx), kind: "custom" };
}
switch (effective) {
case "tenant":
return {
key: `tenant:${ctx.tenantId ?? "anon"}`,
kind: "tenant",
};
case "principal":
return {
key: `principal:${ctx.principal?.id ?? "anon"}`,
kind: "principal",
};
case "ip":
return { key: `ip:${ctx.ip ?? "anon"}`, kind: "ip" };
}
}

View File

@@ -0,0 +1,10 @@
export type { MemoryStoreOptions } from "./memory-store/memory-store.js";
export {
createMemoryRateLimitStore,
MemoryRateLimitStore,
} from "./memory-store/memory-store.js";
export type {
RateLimitBudget,
RateLimitStoreLike,
RateLimitTakeResult,
} from "./store-like/store-like.js";

View File

@@ -0,0 +1,5 @@
export type { MemoryStoreOptions } from "./memory-store.js";
export {
createMemoryRateLimitStore,
MemoryRateLimitStore,
} from "./memory-store.js";

View File

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

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RateLimitError } from "../../error-codes/error-codes.js";
import {
createMemoryRateLimitStore,
MemoryRateLimitStore,
} from "./memory-store.js";
describe("MemoryRateLimitStore", () => {
it("allows requests within burst capacity", { timeout: 5000 }, () => {
const now = 0;
const s = createMemoryRateLimitStore({ clock: () => now });
const budget = { rpm: 60, burst: 5 };
for (let i = 0; i < 5; i++) {
const r = s.take("k", budget, 1);
expect(r.allowed).toBe(true);
}
const denied = s.take("k", budget, 1);
expect(denied.allowed).toBe(false);
expect(denied.retryAfterMs).toBeGreaterThan(0);
expect(denied.remaining).toBe(0);
});
it("refills deterministically against the injected clock", {
timeout: 5000,
}, () => {
let now = 0;
const s = new MemoryRateLimitStore({ clock: () => now });
const budget = { rpm: 60, burst: 1 };
expect(s.take("k", budget, 1).allowed).toBe(true);
expect(s.take("k", budget, 1).allowed).toBe(false);
now += 1_000; // 1s → 1 token at rpm 60
expect(s.take("k", budget, 1).allowed).toBe(true);
});
it("isolates buckets by key", { timeout: 5000 }, () => {
const s = new MemoryRateLimitStore({ clock: () => 0 });
const budget = { rpm: 60, burst: 1 };
expect(s.take("a", budget, 1).allowed).toBe(true);
expect(s.take("b", budget, 1).allowed).toBe(true);
});
it("resets the bucket when the budget object changes", {
timeout: 5000,
}, () => {
const now = 0;
const s = new MemoryRateLimitStore({ clock: () => now });
const b1 = { rpm: 60, burst: 1 };
const b2 = { rpm: 60, burst: 2 };
expect(s.take("k", b1, 1).allowed).toBe(true);
expect(s.take("k", b1, 1).allowed).toBe(false);
// Different budget identity → fresh bucket
expect(s.take("k", b2, 1).allowed).toBe(true);
expect(s.take("k", b2, 1).allowed).toBe(true);
});
it("evict is idempotent", { timeout: 5000 }, () => {
const s = new MemoryRateLimitStore();
const budget = { rpm: 60, burst: 1 };
s.take("k", budget, 1);
expect(s.size).toBe(1);
s.evict("k");
s.evict("k"); // second call is a no-op
expect(s.size).toBe(0);
// Eviction of non-existent key: also no-op
s.evict("never");
expect(s.size).toBe(0);
});
it("sweep removes idle buckets past ttl", { timeout: 5000 }, () => {
let now = 0;
const s = new MemoryRateLimitStore({
clock: () => now,
ttlMs: 1_000,
});
s.take("k", { rpm: 60, burst: 1 }, 1);
expect(s.size).toBe(1);
now += 500;
s.sweep();
expect(s.size).toBe(1); // not past TTL yet
now += 1_000;
s.sweep();
expect(s.size).toBe(0);
});
it("sweep frees capacity so evictOldest is not needed", {
timeout: 5000,
}, () => {
let now = 0;
const s = new MemoryRateLimitStore({
clock: () => now,
maxKeys: 2,
ttlMs: 100,
});
const b = { rpm: 60, burst: 1 };
s.take("a", b, 1);
s.take("b", b, 1);
now = 1_000; // well past TTL
// Cap reached but sweep will purge a + b → no need for evictOldest.
s.take("c", b, 1);
expect(s.size).toBe(1);
});
it("evicts oldest when maxKeys is exceeded after a sweep", {
timeout: 5000,
}, () => {
let now = 0;
const s = new MemoryRateLimitStore({
clock: () => now,
maxKeys: 2,
ttlMs: 60_000,
});
const b = { rpm: 60, burst: 1 };
s.take("a", b, 1);
now = 1;
s.take("b", b, 1);
now = 2;
// Cap reached; sweep cannot free (none past TTL) → evictOldest.
s.take("c", b, 1);
expect(s.size).toBe(2);
// The "a" bucket (oldest) should be the one evicted.
// Re-taking it provisions a fresh bucket.
const r = s.take("a", b, 1);
expect(r.allowed).toBe(true);
});
it("uses default ttlMs and maxKeys when omitted", {
timeout: 5000,
}, () => {
const s = new MemoryRateLimitStore();
s.take("k", { rpm: 60, burst: 1 }, 1);
expect(s.size).toBe(1);
});
it("uses Date.now when no clock supplied", { timeout: 5000 }, () => {
const s = new MemoryRateLimitStore();
const r = s.take("k", { rpm: 60, burst: 1 }, 1);
expect(r.allowed).toBe(true);
});
it("createMemoryRateLimitStore factory works without options", {
timeout: 5000,
}, () => {
const s = createMemoryRateLimitStore();
expect(s).toBeInstanceOf(MemoryRateLimitStore);
expect(s.take("k", { rpm: 60, burst: 1 }, 1).allowed).toBe(true);
});
it("sweep with default now arg", { timeout: 5000 }, () => {
const s = new MemoryRateLimitStore({ ttlMs: 1 });
s.take("k", { rpm: 60, burst: 1 }, 1);
// Sleep substitute: directly call sweep, defaults to Date.now()
s.sweep();
expect(s.size).toBeGreaterThanOrEqual(0);
});
it("rejects invalid budgets with STORE_UNAVAILABLE", {
timeout: 5000,
}, () => {
const s = new MemoryRateLimitStore();
expect(() => s.take("k", { rpm: 0, burst: 1 }, 1)).toThrow(
RateLimitError
);
expect(() => s.take("k", { rpm: 60, burst: 0 }, 1)).toThrow(
RateLimitError
);
});
it("returns retryAfterMs >= 1 on denial", { timeout: 5000 }, () => {
const now = 0;
const s = new MemoryRateLimitStore({ clock: () => now });
const budget = { rpm: 60, burst: 1 };
s.take("k", budget, 1);
const r = s.take("k", budget, 1);
expect(r.allowed).toBe(false);
expect(r.retryAfterMs).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,146 @@
import { TokenBucket } from "@suaveplan/rate-limiter";
import type { Clock } from "@suaveplan/types";
import {
RATELIMIT_CODES,
RateLimitError,
} from "../../error-codes/error-codes.js";
import type {
RateLimitBudget,
RateLimitStoreLike,
RateLimitTakeResult,
} from "../store-like/store-like.js";
/** Construction options for the in-memory store. */
export interface MemoryStoreOptions {
/** Injectable clock — defaults to `Date.now`. */
readonly clock?: Clock;
/**
* Idle TTL in milliseconds. Buckets untouched for longer than this
* are eligible for eviction on the next `sweep()`. Defaults to
* 10 minutes — long enough that bursty traffic re-uses the bucket
* without rebuilding it, short enough to bound memory.
*/
readonly ttlMs?: number;
/**
* Maximum bucket count before forced eviction. Prevents an
* adversarial caller from synthesising unique keys to exhaust
* memory. Defaults to 100_000 — large enough for the typical
* single-host fan-out, small enough to cap RSS.
*/
readonly maxKeys?: number;
}
interface BucketEntry {
readonly bucket: TokenBucket;
lastTouched: number;
readonly budget: RateLimitBudget;
}
/**
* In-memory token-bucket store. Each unique `key` maps to a single
* `TokenBucket` lazily provisioned from `@suaveplan/rate-limiter` on
* first `take`. Buckets refill at `budget.rpm / 60` tokens per second
* (the `TokenBucket` API is `tokens/ms`, so refill rate is
* `budget.rpm / 60_000`). Bucket capacity is `budget.burst`.
*/
export class MemoryRateLimitStore implements RateLimitStoreLike {
private readonly clock: Clock;
private readonly ttlMs: number;
private readonly maxKeys: number;
private readonly buckets = new Map<string, BucketEntry>();
public constructor(options: MemoryStoreOptions = {}) {
this.clock = options.clock ?? (() => Date.now());
this.ttlMs = options.ttlMs ?? 10 * 60 * 1000;
this.maxKeys = options.maxKeys ?? 100_000;
}
public take(
key: string,
budget: RateLimitBudget,
weight: number
): RateLimitTakeResult {
if (budget.burst <= 0 || budget.rpm <= 0) {
throw new RateLimitError(
RATELIMIT_CODES.STORE_UNAVAILABLE,
"invalid rate-limit budget — rpm and burst must be positive",
{ context: { key, rpm: budget.rpm, burst: budget.burst } }
);
}
const now = this.clock();
let entry = this.buckets.get(key);
const budgetMatches =
entry !== undefined &&
entry.budget.rpm === budget.rpm &&
entry.budget.burst === budget.burst;
if (entry === undefined || !budgetMatches) {
// Different budget on the same key resets the bucket — the
// budget is part of the bucket's identity.
if (this.buckets.size >= this.maxKeys) {
this.sweep(now);
if (this.buckets.size >= this.maxKeys) {
// Evict the oldest entry to make room.
this.evictOldest();
}
}
const bucket = new TokenBucket({
capacity: budget.burst,
refillRate: budget.rpm / 60_000,
clock: this.clock,
});
entry = { bucket, lastTouched: now, budget };
this.buckets.set(key, entry);
}
entry.lastTouched = now;
const result = entry.bucket.consume(weight);
// `TokenBucket.consume` sets `retryAfter` (ms) iff `allowed === false`.
const retryAfterMs = result.allowed
? 0
: Math.max(1, (result.retryAfter as number) | 0);
return {
allowed: result.allowed,
remaining: result.remaining,
retryAfterMs,
};
}
public evict(key: string): void {
// idempotent: Map.delete on a missing key is a no-op
this.buckets.delete(key);
}
/** Remove buckets whose `lastTouched` is older than `ttlMs`. */
public sweep(now: number = this.clock()): void {
const cutoff = now - this.ttlMs;
for (const [key, entry] of this.buckets) {
if (entry.lastTouched < cutoff) {
this.buckets.delete(key);
}
}
}
/** Current bucket count — exposed for telemetry / tests. */
public get size(): number {
return this.buckets.size;
}
private evictOldest(): void {
// Caller guarantees buckets.size >= maxKeys > 0 → map is non-empty.
let oldestKey = "";
let oldestTs = Number.POSITIVE_INFINITY;
for (const [key, entry] of this.buckets) {
if (entry.lastTouched < oldestTs) {
oldestTs = entry.lastTouched;
oldestKey = key;
}
}
this.buckets.delete(oldestKey);
}
}
/** Convenience factory — `new MemoryRateLimitStore(opts)`. */
export function createMemoryRateLimitStore(
options: MemoryStoreOptions = {}
): MemoryRateLimitStore {
return new MemoryRateLimitStore(options);
}

View File

@@ -0,0 +1,5 @@
export type {
RateLimitBudget,
RateLimitStoreLike,
RateLimitTakeResult,
} from "./store-like.js";

View File

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

View File

@@ -0,0 +1,50 @@
/**
* Abstract storage interface for the rate-limit middleware. The default
* implementation is the in-memory token-bucket store shipped from
* `./store/memory-store`; a future `@suaveplan/api-rate-limit-redis`
* adapter can land without touching the consumer interface.
*
* `take` is synchronous on purpose — the request pipeline's
* `RequestRateLimiter.consume` contract is also synchronous, and most
* single-process deployments do not need async I/O for a token-bucket
* check. Async stores can wrap their I/O in a tightly-bounded
* synchronous projection (or expose a sibling async helper outside the
* pipeline-gate hot path).
*/
export interface RateLimitBudget {
/** Requests per minute; one minute window. */
readonly rpm: number;
/** Maximum tokens the bucket can hold (burst tolerance). */
readonly burst: number;
}
/** Result of a single `take` call. */
export interface RateLimitTakeResult {
/** True iff the bucket had enough tokens for `weight`. */
readonly allowed: boolean;
/** Tokens remaining in the bucket after a successful take. */
readonly remaining: number;
/** Time (ms) until the bucket refills enough to allow another take. */
readonly retryAfterMs: number;
}
/**
* Storage abstraction. `take(key, budget, weight)` consumes `weight`
* tokens from the bucket identified by `key`. Implementations create
* the bucket on first contact and refill it according to
* `budget.rpm` / 60 tokens per second up to `budget.burst`.
*/
export interface RateLimitStoreLike {
take(
key: string,
budget: RateLimitBudget,
weight: number
): RateLimitTakeResult;
/**
* Drop a bucket from the store. Called when a key has gone idle
* beyond the TTL window. Implementations MUST treat repeated
* eviction of the same key as a no-op (idempotent) so callers can
* sweep without race-tracking.
*/
evict(key: string): void;
}

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("runtime @suaveplan/* deps are exactly the documented set", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual([
"@suaveplan/error",
"@suaveplan/rate-limiter",
"@suaveplan/types",
]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/rate-limiter",
"@suaveplan/types",
"tslib",
"zod",
]);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-rate-limit",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"middleware/index": "src/middleware/index.ts",
"selector/index": "src/selector/index.ts",
"store/index": "src/store/index.ts",
"instruments/index": "src/instruments/index.ts",
"error-codes/index": "src/error-codes/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/rate-limiter",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});