Initial Commit
This commit is contained in:
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { RateLimitCode } from "./error-codes.js";
|
||||
export { RATELIMIT_CODES, RateLimitError } from "./error-codes.js";
|
||||
55
packages/api-stack/api/api-rate-limit/src/index.ts
Normal file
55
packages/api-stack/api/api-rate-limit/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
RateLimitCounterName,
|
||||
RateLimitInstruments,
|
||||
RateLimitInstrumentsConfig,
|
||||
} from "./instruments.js";
|
||||
export {
|
||||
createRateLimitInstruments,
|
||||
RATE_LIMIT_COUNTER_NAMES,
|
||||
} from "./instruments.js";
|
||||
@@ -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,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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
CreateRateLimitMiddlewareOptions,
|
||||
HostRateLimitDefaults,
|
||||
RateLimitDecision,
|
||||
RateLimitInvocation,
|
||||
RateLimitMiddleware,
|
||||
RateLimitRouteOption,
|
||||
RequestRateLimiter,
|
||||
} from "./middleware.js";
|
||||
export {
|
||||
createRateLimitMiddleware,
|
||||
RateLimitRouteOptionSchema,
|
||||
} from "./middleware.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
RateLimitContext,
|
||||
RateLimitKeySelector,
|
||||
ResolvedKey,
|
||||
} from "./selector.js";
|
||||
export { resolveRateLimitKey } from "./selector.js";
|
||||
@@ -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,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;
|
||||
}
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
10
packages/api-stack/api/api-rate-limit/src/store/index.ts
Normal file
10
packages/api-stack/api/api-rate-limit/src/store/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { MemoryStoreOptions } from "./memory-store.js";
|
||||
export {
|
||||
createMemoryRateLimitStore,
|
||||
MemoryRateLimitStore,
|
||||
} from "./memory-store.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
RateLimitBudget,
|
||||
RateLimitStoreLike,
|
||||
RateLimitTakeResult,
|
||||
} from "./store-like.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user