@rate-kit/core
v0.1.0
Published
Framework-agnostic, storage-agnostic rate-limiting engine core primitive.
Maintainers
Readme
@rate-kit/core
A small, framework-agnostic, storage-agnostic rate-limiting primitive for Node.js + TypeScript. It orchestrates a pluggable algorithm and pluggable storage; it does not ship an HTTP server, a dashboard, or opinions about your framework.
- Zero runtime dependencies
- Strict TypeScript, dual ESM/CJS output with declaration maps
- Concurrency-safe: every state mutation goes through a storage-level compare-and-swap (CAS), retried under contention
- Deterministic: every algorithm is a pure function of
(state, config, now), fully testable with a fake clock - Runtime methods never throw. All failures — a closed engine, a lock
conflict, storage contention, a throwing hook, a missing
LockStore— are reported as a safe fallback return value and/or anerrorevent, never an exception. The only thing that throws isnew RateEngine(config)itself, for genuine developer/config mistakes (ConfigurationError). - 6 algorithms:
token_bucket,fixed_window,sliding_window(exact),sliding_window_counter(approximate),leaky_bucket,gcra
Install
npm install @rate-kit/core @rate-kit/storage-memory
# or
pnpm add @rate-kit/core @rate-kit/storage-memory@rate-kit/core ships no storage implementation of its own — not even for
tests. @rate-kit/storage-memory provides MemoryStorage, an in-process
RateEngineStorage/LockStore implementation, for local development,
testing, or single-process production use. For distributed deployments,
implement RateEngineStorage against Redis/PostgreSQL/etc. yourself — see
STORAGE_CONTRACT.md.
Quick start
import { RateEngine } from "@rate-kit/core";
import { MemoryStorage } from "@rate-kit/storage-memory";
interface Req {
userId: string;
}
const engine = new RateEngine<Req>({
storage: new MemoryStorage(),
context: () => ({ userId: currentUserId() }),
policy: {
default: {
algorithm: "token_bucket",
limit: 100,
windowMs: 60_000,
burst: 20,
key: ({ request }) => `user:${request.userId}`
}
}
});
const result = await engine.consume();
if (!result.allowed) {
// result.retryAfterMs, result.resetAt, RateEngine.headers(result)
}Anatomy of a policy
RateEngineConfig.policy is a single policy (there is no map of named
policies — one engine instance, one policy). It is scoped and self-contained
— every field is optional except default:
policy: {
config?: { operator?: "first-match" | "and" }; // applied after a rule is matched
default: RuleConfig; // required fallback
resolver?: (input, lctx) => Promise<ResolvedRule | null | undefined>;
rules?: RuleConfig[];
}Resolution order is always resolver → rules → default:
- If
resolveris set and returns a rule, that rule is used, full stop. - Otherwise,
rules(if present) are scanned in declaration order; a rule matches if it has nowhenor itswhencondition evaluates true against the request.- Under the default
"first-match"operator, the first match wins. - Under
"and", every matching rule must allow the request (see Scenario: composite limits).
- Under the default
- If nothing matched (or
rulesis absent),defaultis used.defaultis the single place to express "what happens when nothing else matches" — you never need an always-true catch-all rule at the end ofrules.
limit, windowMs, burst, cost, refillRate, and key on a rule may
each be a static value or a function of { request, helpers, subject,
action } (see DynamicInput).
A RuleConfig also accepts an optional tags?: string[] field, reserved
for future use — the engine does not currently read, match, or resolve
against it. Setting it today is a no-op; it exists so rules can start
carrying tag data without a breaking change once tag-based matching lands.
consume() request pipeline
before:resolvehook fires.- Allow/deny lists are checked, BEFORE any rule is resolved — against
input.subject(not the eventual rate-limit key). This means a denied or allow-listed subject never triggersresolver/rulesevaluation at all.- Allow-list match → returns immediately with a sentinel result:
limit = Number.MAX_SAFE_INTEGER,remaining = Number.MAX_SAFE_INTEGER,usage = 0,retryAfterMs = 0,allowed: true. - Deny-list match → returns immediately with
allowed: false,limit: 0,remaining: 0,retryAfterMs: 0.
- Allow-list match → returns immediately with a sentinel result:
- Otherwise, the policy is resolved (
after:resolve,before:decidehooks), the algorithm runs its CAS-guarded decision, andafter:decidefires with a frozen (readonly) result. allowed/rejected, optionallythrottled, andresultevents fire.
Algorithms
Each algorithm is a pure function registered by name. Pick one per rule via
algorithm: "token_bucket" | "fixed_window" | "sliding_window" |
"sliding_window_counter" | "leaky_bucket" | "gcra" (defaults to
token_bucket). These string literals are also available as the
ALGORITHM_NAMES const object / AlgorithmName type exported from the
package root.
| Algorithm | Notes |
|---|---|
| token_bucket | Continuous refill. Supports burst and reserve() (has refund()). |
| fixed_window | Simplest baseline; resets on discrete boundaries. |
| sliding_window | Exact log of timestamps; accurate but O(n) memory per key. |
| sliding_window_counter | Two weighted counters; O(1) memory, approximate. |
| leaky_bucket | Continuous drain. Supports burst and reserve() (has refund()). |
| gcra | Single timestamp (TAT) per key; very memory-efficient. |
Only token_bucket and leaky_bucket implement refund(state, cost, nowMs)
on the RateEngineAlgorithm interface, which is what makes them the only two
algorithms that support reserve()/rollback(). A custom algorithm can
implement refund the same way to support reservations too — the engine
never special-cases an algorithm by name.
Core primitives
| Method | Purpose |
|---|---|
| consume(input) | Check quota and deduct if allowed. |
| peek(input) | Check quota without deducting. Returns the full result, including action. |
| reserve(input) | Holds quota via the same CAS-guarded deduction as consume(), but emits reserved instead of allowed/rejected. commit() finalizes (no-op — the deduction already happened); rollback() calls the algorithm's refund() to release the hold. If the algorithm doesn't support reservations, returns { result: { allowed: false, ... }, commit: noop, rollback: noop } — never throws. |
| increment(input) | Adds to a counter. mode: "bypass" (default): a direct, unresolved storage write — no policy resolution, no hooks, no events; fastest path for trusted/internal counters. mode: "enforced": resolves the policy first (so before:resolve/after:resolve hooks run and the key matches what consume()/peek() would use) and emits an incremented event; still never blocks. |
| reset(key) | Clear all state for a raw storage key (no rule resolution — same "bypass" semantics as increment). |
| consumeMany(inputs) | Sequential batch convenience. Non-atomic. |
| clone(overrides?) | Returns a new, independent RateEngine sharing this instance's config (storage, policy, lists, algorithms, ...) unless overridden. Hooks, event listeners, and the closed flag are never shared — only pass a storage/lockStore override if you also need independent infrastructure. See Scenario: singleton vs. multiple engines. |
| forKey(subject) | Returns { consume, peek, reserve, increment, reset } pre-bound to subject. reset() here does resolve the policy first, so it clears the actual key consume()/peek() would use for that subject. |
| lock(key) / unlock(key) / isLocked(key) | Distributed mutex primitive, independent of rate limiting. lock() returns { acquired: boolean } — it never throws, even on conflict or a missing LockStore. |
| health() | Storage health check. |
| reconfigure(partial) | Replaces (does not merge) whichever fields you pass — e.g. passing policy swaps the entire policy object, not a deep merge of rules. |
| close() | Graceful shutdown. Idempotent. |
DynamicInput
The input object passed to every dynamic (function-valued) rule field, and
to policy.resolver:
interface DynamicInput<C, H, A extends string> {
request: C;
helpers: H;
subject?: string; // from ConsumeInput.subject / forKey()
action?: A; // from ConsumeInput.action
}actions — per-action overrides
RateEngineConfig.actions is an optional map from ConsumeInput.action to a
partial RuleConfig that's shallow-merged onto whatever rule the policy
resolved, before dynamic values are materialized. It's a lightweight way to
give a handful of named operations ("login", "signup", "export", ...)
their own limits without writing a full when-conditioned rule for each.
It's read-only at runtime via engine.actions (handy from inside a hook via
lctx.engine.actions).
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { limit: 1000, windowMs: 60_000, key: "default" } },
actions: {
login: { limit: 5, windowMs: 60_000, key: "login" },
signup: { limit: 3, windowMs: 3_600_000, key: "signup" }
}
});
await engine.consume({ action: "login" }); // uses the 5/min overridehelpers — injected services
RateEngineConfig.helpers is an arbitrary value (a DB client, a feature-flag
service, a logger, ...) made available as helpers in DynamicInput,
policy.resolver, and lctx.helpers inside hooks — without the engine
knowing or caring what it is.
interface Helpers {
billing: BillingClient;
}
const engine = new RateEngine<Req, Helpers>({
storage: new MemoryStorage(),
helpers: { billing: billingClient },
policy: {
default: { limit: 50, windowMs: 60_000, key: "default" },
resolver: async ({ request, helpers }) => {
const plan = await helpers.billing.getPlan(request.userId);
return plan ? { algorithm: "token_bucket", limit: plan.rpm, windowMs: 60_000, key: `plan:${request.userId}`, name: plan.name } : null;
}
}
});clock — deterministic time
RateEngineConfig.clock overrides the default performance.now()-backed
clock with any () => number returning monotonic milliseconds. Every
algorithm receives nowMs as an explicit argument rather than reading time
itself, so swapping the clock makes the whole engine deterministic — see
Scenario: fake clock.
serializers — sanitizing data before it reaches storage
RateEngineConfig.serializers is a small, overridable set of functions
that sanitize/transform sensitive or runtime data before it crosses a
boundary the core doesn't control — today, that's the storage boundary
(every RateEngineStorage call can carry a context field; see
Storage below). It contains no domain-specific logic itself —
it's purely an extension point.
serializers?: {
requestForStorage?: (input: { request: C; helpers: H }) => unknown;
};The default omits the request entirely —
requestForStorage: () => undefined — so no storage adapter ever sees your
raw request/context object (an Express Request, a Koa ctx, ...)
unless you explicitly opt in:
const engine = new RateEngine<Req>({
storage: new MemoryStorage(),
context: () => currentRequest, // has headers, cookies, everything
serializers: {
// Only this safe projection ever reaches a storage call's `context` field.
requestForStorage: ({ request }) => ({ ip: request.ip, path: request.path })
},
policy: { default: { limit: 100, windowMs: 60_000, key: ({ subject }) => subject ?? "anon" } }
});requestForStorage's return type is called SC (storage context) — it's
what RateEngineStorage<SC> is actually parameterized by, never your raw
C. A storage adapter's type signature has no way to even reference the
full, possibly-sensitive request type; see
STORAGE_CONTRACT.md for the
full details, including how to get stronger-than-unknown typing for SC
if your adapter wants it.
Scenarios & examples
Fixed limit, single rule
The simplest possible policy: no conditions, default handles every request.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { algorithm: "fixed_window", limit: 100, windowMs: 60_000, key: "global" } }
});
await engine.consume(); // { allowed: true, remaining: 99, ... }Per-subject dynamic key
Pass an identity via input.subject and reference it from key, instead of
threading it through request.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: {
default: { algorithm: "token_bucket", limit: 60, windowMs: 60_000, key: ({ subject }) => `user:${subject}` }
}
});
await engine.consume({ subject: "user_42" });Tiered limits with when conditions + default fallback
rules are checked first; default covers everyone who doesn't match a
tier-specific rule.
interface Req {
tier: "free" | "pro" | "enterprise";
}
const engine = new RateEngine<Req>({
storage: new MemoryStorage(),
context: () => getRequestContext(),
policy: {
default: { limit: 50, windowMs: 60_000, key: ({ subject }) => `free:${subject}` },
rules: [
{ when: { eq: { tier: "enterprise" } }, limit: 10_000, windowMs: 60_000, key: ({ subject }) => `ent:${subject}` },
{ when: { eq: { tier: "pro" } }, limit: 1_000, windowMs: 60_000, key: ({ subject }) => `pro:${subject}` }
]
}
});
await engine.consume({ subject: "user_42" });when supports eq, gt, gte, lt, lte, in, exists, not, all,
any, and an escape-hatch predicate: (ctx) => boolean | Promise<boolean>
for anything the declarative matchers can't express.
External resolver
For limits that live in a database, feature-flag service, or anywhere else
outside the static rule list. The resolver runs first; returning
null/undefined falls through to rules/default.
const engine = new RateEngine<Req>({
storage: new MemoryStorage(),
policy: {
default: { limit: 50, windowMs: 60_000, key: "default" },
resolver: async ({ subject }) => {
const plan = await billingService.getPlan(subject);
if (!plan) return null; // fall through to rules/default
return { algorithm: "token_bucket", limit: plan.requestsPerMinute, windowMs: 60_000, key: `plan:${subject}`, name: plan.name };
}
}
});Composite limits with the and operator
Require multiple rules to all allow — e.g. a burst limit and a daily cap. Every matching rule is peeked first; the request only commits if all of them would allow it.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: {
config: { operator: "and" },
default: { limit: 10, windowMs: 1_000, key: "fallback-burst" },
rules: [
{ limit: 10, windowMs: 1_000, key: "burst" },
{ limit: 1_000, windowMs: 86_400_000, key: "daily" }
]
}
});peek() — check without consuming
Returns the full RateEngineResult shape, including action — nothing is
stripped.
const status = await engine.peek({ subject: "user_42", action: "read" });
console.log(status.remaining, status.resetAt, status.action);reserve() / commit() / rollback() — hold, then finalize or release
reserve() performs the exact same CAS-guarded deduction consume() does —
the quota really is held/spent at reservation time — but through a distinct
code path that emits reserved instead of allowed/rejected. commit()
is then a no-op (the deduction is already final); rollback() calls the
algorithm's own refund() to give the quota back. If the algorithm doesn't
support reservations (only token_bucket and leaky_bucket do),
reserve() returns a rejected result with no-op commit/rollback instead
of throwing.
const reservation = await engine.reserve({ subject: "user_42" });
if (!reservation.result.allowed) {
return respondWithRetryAfter(reservation.result);
}
try {
await doExpensiveWork();
await reservation.commit();
} catch (err) {
await reservation.rollback(); // refunds the held quota
throw err;
}increment() — bypasses policy/rule resolution (by default)
increment() defaults to bypass mode: it never resolves policy — it
writes straight to storage.increment() on a key derived from subject or
an explicit key, completely independent of any configured limit, with no
hooks and no events. Use it for arbitrary usage counters (bytes
transferred, items processed, ...).
await engine.increment({ subject: "user_42", amount: responseSizeInBytes });
// or an explicit key, ignoring subject:
await engine.increment({ key: "bandwidth:tenant-7", amount: responseSizeInBytes });Pass mode: "enforced" to resolve the policy first — so before:resolve/
after:resolve hooks run and the counter key matches whatever
consume()/peek() would use for that subject/action — and to emit an
incremented event. Enforced mode still never blocks; it only ever writes.
await engine.increment({ subject: "user_42", action: "download", amount: 1, mode: "enforced" });consume() and increment() are never atomic with each other, or with
themselves across calls. They write to different keys (increment()
appends :incr), each individually atomic per-key via cas(), but there
is no cross-key transaction anywhere in this package — see
No cross-key transactions
in STORAGE_CONTRACT.md. A crash between a consume()/reserve() call
and a follow-up increment() "true-up" (as in the
AI/LLM scenario below)
leaves them diverged, silently.
dryRun — shadow mode for a single call
Evaluate a decision, fire hooks/events, but never persist state.
const shadow = await engine.consume({ subject: "user_42", dryRun: true });
logger.info("would have been", shadow.allowed ? "allowed" : "rejected");reset() and consumeMany()
engine.reset(key) takes a raw storage key, exactly as-is — it never
resolves the policy. It is the same "bypass" tier as increment()'s
default mode. Do not pass a subject string to engine.reset(...)
expecting it to behave like forKey(subject).reset() (below) — if your
rule's key is a function of subject (e.g.
key: ({ subject }) => user:${subject}``), engine.reset(subject) clears
the wrong key (the raw subject string, not user:${subject}) and silently
no-ops against the real rate-limit state.
await engine.reset("user:123"); // raw storage key, no rule resolution
const results = await engine.consumeMany([{}, {}, {}]); // sequential, non-atomicforKey() — a subject-bound handle
forKey(subject).reset() is the one place reset does resolve the
policy — it looks up the same rule consume()/peek() would use for this
subject and clears whatever key that rule actually resolved to, correctly
handling a dynamic key function.
const alice = engine.forKey("user_42");
await alice.consume();
await alice.peek();
const r = await alice.reserve();
await r.commit();
await alice.increment({ amount: 3 });
await alice.reset(); // resolves the policy for "user_42" and clears the real keyDistributed lock primitive
Independent of rate limiting. lock() never throws — a conflict or a
missing LockStore both just return { acquired: false } (with an error
event for observability).
const { acquired } = await engine.lock("nightly-job", { ttlMs: 60_000 });
if (!acquired) return; // someone else holds it, or no LockStore configured
try {
await runNightlyJob();
} finally {
await engine.unlock("nightly-job");
}
const state = await engine.isLocked("nightly-job"); // { locked: boolean, expiresAt?, reason? }Hooks — inline, awaited, can mutate
Hooks run inline and are awaited; a hook can mutate the pending input (e.g.
override cost) via payload.input, or use lctx.controller. after:decide
exposes a frozen (readonly) result. A throwing hook is caught and
reported via the error event — { error, stage, context: lctx.request } —
never breaks the operation.
engine.hook("before:resolve", (payload) => {
payload.input.cost = estimateCost(payload.input.subject);
});
engine.on("error", ({ error, stage, context }) => logger.warn("hook failed", { stage, error, context }));Events — fire-and-forget observability
Listeners may be async; a rejected listener promise is caught the same
way a synchronous throw is (fire-and-forget — it never affects the
operation or crashes the process).
engine.on("allowed", ({ result }) => metrics.increment("ratelimit.allowed"));
engine.on("rejected", ({ result }) => metrics.increment("ratelimit.rejected"));
engine.on("throttled", ({ result }) => logger.warn("near limit", result));
engine.once("error", ({ error, stage, context }) => logger.error("rate-kit error", { error, stage, context }));Available events: allowed, rejected, throttled, result, reserved,
reserved:commit, reserved:rollback, lock, unlock, incremented,
error — also exported as the ENGINE_EVENTS const object / EngineEvent
type.
Hooks and events are engine-scoped, not request-scoped
engine.hook(...)/engine.on(...) register against the engine
instance, not against any particular request. On a shared singleton,
calling engine.hook("before:resolve", handler) once affects every
concurrent in-flight request from that point on — there is no per-request
hook/listener scoping. This is the expected behavior for the singleton
pattern this package is designed around (see
Singleton vs. multiple engines), but it's
worth stating explicitly since it's easy to assume hooks/listeners are
somehow request-local. If you need genuinely isolated hook/event behavior
(e.g. two different subsystems that each want their own observability
without seeing each other's), use clone() to get a second engine instance
with its own independent hooks/listeners — clone() never shares those,
even when it shares the underlying storage.
Allow/deny lists — evaluated before any rule
Lists key off input.subject, checked before policy.resolver/rules
ever run, so a denied subject never triggers a resolver call. See
the sentinel values documented above.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { limit: 10, windowMs: 60_000, key: ({ subject }) => subject ?? "anon" } },
lists: {
deny: ["1.2.3.4"], // or an async function returning string[]
allow: async () => await loadAllowlistFromDb()
}
});
await engine.consume({ subject: "1.2.3.4" }); // { allowed: false, limit: 0, remaining: 0, retryAfterMs: 0, ... }HTTP integration via RateEngine.headers()
Framework-agnostic — works with any request/response objects.
const result = await engine.consume({ subject: req.ip });
const headers = RateEngine.headers(result);
// { "RateLimit-Limit": "100", "RateLimit-Remaining": "42", "RateLimit-Reset": "17" }
// includes "Retry-After" only when result.allowed === false
for (const [name, value] of Object.entries(headers)) {
res.setHeader(name, value);
}
if (!result.allowed) {
res.status(429).end();
}retryAfterMs (and the Retry-After header derived from it) is exact —
add your own jitter before using it as a client backoff delay, to avoid a
thundering herd of retries all landing on the same millisecond.
Custom algorithms
Register your own algorithm (a pure function) alongside the 6 built-ins.
Implement refund too if it should support reserve().
import type { RateEngineAlgorithm } from "@rate-kit/core";
const alwaysAllow: RateEngineAlgorithm = {
name: "always_allow",
supportsReservation: false,
initialState: () => null,
consume: (_state, config) => ({ allowed: true, newState: null, remaining: config.limit, usage: 0, resetAtMs: 0, retryAfterMs: 0 }),
peek: (_state, config) => ({ allowed: true, newState: null, remaining: config.limit, usage: 0, resetAtMs: 0, retryAfterMs: 0 })
};
const engine = new RateEngine({
storage: new MemoryStorage(),
algorithms: [alwaysAllow],
policy: { default: { algorithm: "always_allow", limit: 1, windowMs: 1000, key: "k" } }
});Fake clock for deterministic tests
import { createManualClock, RateEngine } from "@rate-kit/core";
import { MemoryStorage } from "@rate-kit/storage-memory";
const clock = createManualClock(0);
const engine = new RateEngine({
storage: new MemoryStorage({ clock }),
clock,
policy: { default: { algorithm: "token_bucket", limit: 1, windowMs: 1000, key: "k" } }
});
await engine.consume(); // allowed
await engine.consume(); // rejected
clock.advance(1000);
await engine.consume(); // allowed again, deterministicallyHot-swapping the policy at runtime
reconfigure() replaces, it does not deep-merge — passing policy swaps
the whole policy object.
await engine.reconfigure({
policy: { default: { limit: 500, windowMs: 60_000, key: "default-v2" } }
});No atomicity barrier with in-flight requests. Each field assignment
inside reconfigure() is synchronous, so a new value takes effect for any
resolution that starts immediately afterward — but any
consume()/peek()/reserve()/enforced-increment() call already past
its own policy resolution when reconfigure() runs completes against
whatever it already captured; it is not rolled back or fast-forwarded onto
the new config. There is no versioning and no queueing of in-flight
decisions in v0.1.0 — a request never observes a torn/partial config, but
the set of concurrently in-flight requests as a whole can complete against
a mix of old and new config during the moment reconfigure() runs. For a
singleton reloaded rarely (e.g. an admin-panel-driven config change), this
is normally the expected and desired behavior; it's stated explicitly here
because it's easy to assume otherwise.
Health checks & graceful shutdown
const health = await engine.health(); // { ok, latencyMs, storage: { ok, latencyMs } }
process.on("SIGTERM", async () => {
await engine.close(); // idempotent; every method called afterward returns a safe fallback + emits "error"
});Singleton vs. multiple engines
One RateEngine per process is the common case — resolve everything
(action, subject, tier, tenant, failure history) dynamically through
policy.resolver/rules/actions rather than constructing a new engine
per request. Reach for clone() only when you genuinely need a second,
independently-configured instance (e.g. a background-worker engine with its
own policy, sharing the same Redis-backed storage as the request-serving
one):
const requestEngine = new RateEngine({
storage: redisStorage,
policy: { default: { limit: 1000, windowMs: 60_000, key: ({ subject }) => `req:${subject}` } }
});
// Same storage backend, its own policy, and its own hooks/events/closed state.
const workerEngine = requestEngine.clone({
policy: { default: { limit: 50, windowMs: 60_000, key: ({ subject }) => `job:${subject}` } }
});⚠️ DO NOT capture a context snapshot — context must be a live getter
RateEngineConfig.context is a function, called fresh on every single
consume()/peek()/reserve()/enforced-increment() call, precisely so
a per-request store (Strapi's requestContext, an AsyncLocalStorage,
etc.) is read at the moment each rate-limit decision actually happens. The
most common way to break this is capturing the current value once,
outside the getter, instead of passing the getter itself:
// ❌ DO NOT do this — captures ONE request's context at singleton-construction
// time and then hands that same snapshot to every request forever after.
const ctx = strapi.requestContext.getStore();
strapi.core.limiter = new RateEngine({
context: () => ctx, // always returns the snapshot from above, never re-reads the store
storage: redisStorage,
policy: { /* ... */ }
});// ✅ Correct — the arrow function itself IS the live getter; it's called
// fresh, inside the engine, every time a rate-limit decision runs.
strapi.core.limiter = new RateEngine({
context: () => strapi.requestContext.getStore(),
storage: redisStorage,
policy: { /* ... */ }
});The snapshot version compiles and even looks correct in a quick test (there
is a request in flight when you construct the singleton at bootstrap!) —
which is exactly what makes it dangerous: it works the first time and then
silently serves every subsequent request's rate-limit decisions using the
very first request's context, forever, for the lifetime of the process.
This is a mistake in your integration code, not something
@rate-kit/core can detect or guard against — the engine has no way to
distinguish "a function that happens to always return the same value" from
"a function that reads live per-request state." Always pass the getter
itself (context: () => strapi.requestContext.getStore()), never its
already-called result (context: () => capturedResult).
Real-world scenarios
Login brute-force protection
Track failed attempts per account with increment() (bypass mode — a raw,
unresolved counter is exactly right here), then have the policy's resolver
read that counter (via helpers, your own storage lookup) to escalate the
effective limit down — or lock the account outright — the more failures
pile up.
interface LoginReq {
username: string;
}
interface Helpers {
getFailureCount(username: string): Promise<number>;
}
const engine = new RateEngine<LoginReq, Helpers>({
storage: new MemoryStorage(),
helpers: { getFailureCount: (username) => readFailureCounterFromYourStore(username) },
policy: {
default: { limit: 5, windowMs: 60_000, key: ({ subject }) => `login:${subject}` },
resolver: async ({ subject, helpers }, lctx) => {
const failures = await helpers.getFailureCount(subject!);
if (failures >= 10) {
// Escalate to a hard lock instead of just a tighter limit.
await lctx.controller.lock(`login-lock:${subject}`, { ttlMs: 15 * 60_000, reason: "too many failed logins" });
return { algorithm: "fixed_window", limit: 0, windowMs: 15 * 60_000, key: `login-lock:${subject}`, name: "locked" };
}
if (failures >= 3) {
// Progressive escalation: fewer attempts allowed the more it fails.
return { algorithm: "fixed_window", limit: Math.max(1, 5 - failures), windowMs: 60_000, key: `login:${subject}`, name: "escalated" };
}
return null; // fall through to `default`
}
}
});
async function attemptLogin(username: string, password: string) {
const result = await engine.consume({ subject: username, action: "login" });
if (!result.allowed) return { ok: false, retryAfterMs: result.retryAfterMs };
const success = await verifyPassword(username, password);
if (!success) {
await engine.increment({ key: `login-failures:${username}`, amount: 1 });
} else {
await engine.reset(`login-failures:${username}`); // clear the streak on success
}
return { ok: success };
}IP + user dual limits (and policy)
Combine a per-IP limit with a per-user limit — both must allow. subject
carries the user id; the IP comes from request.
interface Req {
ip: string;
}
const engine = new RateEngine<Req>({
storage: new MemoryStorage(),
context: () => ({ ip: currentRequestIp() }),
policy: {
config: { operator: "and" },
default: { limit: 20, windowMs: 60_000, key: ({ request }) => `ip:${request.ip}` },
rules: [
{ limit: 20, windowMs: 60_000, key: ({ request }) => `ip:${request.ip}` },
{ limit: 100, windowMs: 60_000, key: ({ subject }) => `user:${subject}` }
]
}
});
await engine.consume({ subject: "user_42" }); // blocked if EITHER the IP or the user is over its own limitWebSocket message rate limiting
Consume per message with a variable cost (e.g. larger payloads cost more),
and close the socket once the connection is clearly abusive rather than
just dropping individual messages forever.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { algorithm: "token_bucket", limit: 100, windowMs: 10_000, burst: 20, key: ({ subject }) => `ws:${subject}` } }
});
socket.on("message", async (raw) => {
const result = await engine.consume({ subject: connectionId, cost: Math.ceil(raw.length / 1024) });
if (!result.allowed) {
if (result.usage > result.limit * 3) {
// Wildly over budget — this connection isn't going to behave; close it.
socket.close(1008, "rate limit exceeded");
return;
}
socket.send(JSON.stringify({ error: "rate_limited", retryAfterMs: result.retryAfterMs }));
return;
}
handleMessage(raw);
});GraphQL query complexity
Compute a query's cost from its shape (field count, list multipliers, ...)
using whatever complexity library/estimator you already have, and pass it
as cost — the engine doesn't need to know anything about GraphQL.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { algorithm: "token_bucket", limit: 10_000, windowMs: 60_000, burst: 2_000, key: ({ subject }) => `gql:${subject}` } }
});
async function handleGraphQLRequest(query: string, subject: string) {
const cost = estimateQueryComplexity(query); // your own complexity calculator
const result = await engine.consume({ subject, cost });
if (!result.allowed) {
throw new GraphQLError("Query too complex for current rate limit", { extensions: { retryAfterMs: result.retryAfterMs } });
}
return executeQuery(query);
}Background job / queue worker
Rate-limit job execution (e.g. calls to a downstream API with its own limits) and translate a rejection into your job system's native retry/backoff mechanism instead of dropping or failing the job outright.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { algorithm: "leaky_bucket", limit: 5, windowMs: 1_000, key: ({ subject }) => `downstream:${subject}` } }
});
async function processJob(job: Job) {
const result = await engine.consume({ subject: job.downstreamService });
if (!result.allowed) {
// Re-queue with the engine's own retryAfterMs instead of a fixed backoff.
await job.retryLater(result.retryAfterMs);
return;
}
await callDownstreamApi(job);
}AI/LLM token limiting — estimate, then true-up
Reserve an estimated token cost before calling the model (so a burst of
concurrent requests can't blow past budget while responses are in flight),
then correct the difference once the real usage is known using enforced
increment() — which resolves the same policy key consume() used, so the
adjustment lands on the right counter.
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: { default: { algorithm: "token_bucket", limit: 100_000, windowMs: 60_000, key: ({ subject }) => `llm:${subject}` } }
});
async function callModel(prompt: string, subject: string) {
const estimatedTokens = estimateTokenCount(prompt);
const reservation = await engine.reserve({ subject, cost: estimatedTokens });
if (!reservation.result.allowed) {
throw new Error(`Token budget exceeded, retry in ${reservation.result.retryAfterMs}ms`);
}
try {
const response = await model.complete(prompt);
await reservation.commit();
const actualTokens = response.usage.totalTokens;
const delta = actualTokens - estimatedTokens; // negative if we overestimated
if (delta !== 0) {
await engine.increment({ subject, amount: delta, mode: "enforced" });
}
return response;
} catch (err) {
await reservation.rollback(); // model call failed — give the estimated tokens back
throw err;
}
}Multi-tenant SaaS
Resolve each tenant's plan dynamically via resolver, with a safe fallback
when the policy source (a database, a billing service, ...) is unavailable
— resolver throwing never crashes the request; it's reported via the
error event and the engine falls back to rules/default.
interface Req {
tenantId: string;
}
const engine = new RateEngine<Req, { tenantPlans: TenantPlanStore }>({
storage: new MemoryStorage(),
helpers: { tenantPlans: tenantPlanStore },
policy: {
default: { limit: 100, windowMs: 60_000, key: ({ subject }) => `tenant:${subject}:fallback` },
resolver: async ({ subject, helpers }) => {
try {
const plan = await helpers.tenantPlans.get(subject!);
if (!plan) return null;
return { algorithm: "token_bucket", limit: plan.rpm, windowMs: 60_000, key: `tenant:${subject}`, name: plan.tier };
} catch {
return null; // plan store unavailable — fall through to the safe `default`
}
}
},
lists: {
deny: async ({ helpers }) => helpers.tenantPlans.listSuspendedTenantIds()
}
});
engine.on("error", ({ error, stage }) => logger.warn("policy resolution degraded", { stage, error }));
await engine.consume({ subject: tenantId });Time-based policy
Vary limits by business hours/weekend with a pure calculation in resolver
— no database lookup needed when the rule is just "what time is it".
function isBusinessHours(date: Date): boolean {
const day = date.getUTCDay();
const hour = date.getUTCHours();
return day >= 1 && day <= 5 && hour >= 9 && hour < 17;
}
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: {
default: { limit: 1000, windowMs: 60_000, key: "off-hours" },
resolver: async () => {
if (isBusinessHours(new Date())) {
return { algorithm: "token_bucket", limit: 100, windowMs: 60_000, key: "business-hours", name: "business-hours" };
}
return null; // fall through to the more generous off-hours `default`
}
}
});Feature-flag gradual rollout
Deterministically bucket a subject into an old vs. new policy by a
percentage, using a stable hash of the subject so the same user always
lands in the same bucket for the lifetime of the rollout — no external
feature-flag service required (though helpers can wrap one if you have
it).
import { createHash } from "node:crypto";
function bucketPercent(subject: string): number {
const hash = createHash("sha1").update(subject).digest();
return (hash.readUInt32BE(0) % 10_000) / 100; // deterministic 0–100 value
}
const ROLLOUT_PERCENT = 25; // 25% of subjects get the new, stricter policy
const engine = new RateEngine({
storage: new MemoryStorage(),
policy: {
default: { limit: 1000, windowMs: 60_000, key: ({ subject }) => `legacy:${subject}` },
resolver: async ({ subject }) => {
if (subject && bucketPercent(subject) < ROLLOUT_PERCENT) {
return { algorithm: "token_bucket", limit: 200, windowMs: 60_000, key: `rollout:${subject}`, name: "rollout" };
}
return null; // remaining subjects fall through to the legacy `default`
}
}
});Storage
@rate-kit/core has no storage implementation of its own —
RateEngineStorage<SC> is generic (parameterized by SC, the sanitized
storage-context type — see serializers
— never your raw request type), initialization-aware
(storage.initialize({ context, clock }) is called once, at
construction), and every operation's params carry rich, optional context
(operation, timestamp, subject, action, a sanitized context,
metadata, and a slim rule summary) so a backend can do backend-specific
filtering, partitioning, indexing, or auditing without any change to the
engine itself. mget/mset are part of the contract for adapter
convenience and future engine optimization — RateEngine doesn't call
either today. There is no cross-key transaction anywhere in this
package (see increment()
above) — cas() is atomic per key, and per key only.
@rate-kit/storage-memory's MemoryStorage implements both
RateEngineStorage and LockStore for in-process use. See
STORAGE_CONTRACT.md for the full interface any
adapter (Redis, SQL, ...) must implement — including a
worked-out architecture for a Redis +
SQL-backed adapter.
Scope
This package is intentionally small. wait(), queue(), penalty(),
acquire()/semaphores, storage failover, and shadow-mode-as-global-config
are not in core — they are (or will be) separate packages
(@rate-kit/storage-memory — available today; @rate-kit/wait,
@rate-kit/queue, @rate-kit/semaphore, @rate-kit/fallback,
@rate-kit/storage-redis, @rate-kit/storage-sql, @rate-kit/otel) built
on top of this primitive. dryRun on consume() covers ad-hoc shadow-mode
testing.
Development
This package lives inside a pnpm workspace. From the repo root:
pnpm install
pnpm --filter @rate-kit/core typecheck
pnpm --filter @rate-kit/core build
pnpm --filter @rate-kit/core test
pnpm format # prettier --write
pnpm changeset # record a change before release