npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rate-kit/core

v0.1.0

Published

Framework-agnostic, storage-agnostic rate-limiting engine core primitive.

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 an error event, never an exception. The only thing that throws is new 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 resolverrulesdefault:

  1. If resolver is set and returns a rule, that rule is used, full stop.
  2. Otherwise, rules (if present) are scanned in declaration order; a rule matches if it has no when or its when condition 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).
  3. If nothing matched (or rules is absent), default is used. default is the single place to express "what happens when nothing else matches" — you never need an always-true catch-all rule at the end of rules.

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

  1. before:resolve hook fires.
  2. 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 triggers resolver/rules evaluation 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.
  3. Otherwise, the policy is resolved (after:resolve, before:decide hooks), the algorithm runs its CAS-guarded decision, and after:decide fires with a frozen (readonly) result.
  4. allowed/rejected, optionally throttled, and result events 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 override

helpers — 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 entirelyrequestForStorage: () => 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-atomic

forKey() — 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 key

Distributed 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, deterministically

Hot-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 limit

WebSocket 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