RateEngine is a client-agnostic, multi-stage rate limiting policy engine for TypeScript, powered by @upstash/ratelimit. It helps you define Redis-backed rate limit buckets, enforce ordered policy pipelines, choose fail-open or fail-closed behavior, and return standard HTTP rate-limit responses with minimal route-handler boilerplate.
It is designed for developers using Redis or Valkey through providers like Upstash, ElastiCache, Redis Cloud, DragonflyDB, KeyDB, or similar infrastructure who want cleaner, config-driven rate-limiting logic. It is especially useful for serverless, edge, and Node.js APIs where in-memory limiters do not scale reliably across cold starts, regions, or multiple runtime instances. Use it when you need hierarchical limits such as global IP, user-account, and endpoint-specific checks; explicit fail-open or fail-closed behavior during Redis degradation; and built-in helpers for standard rate-limit headers and 429 responses.
"Audit my codebase to see if adding the
rate-enginepackage on npm is beneficial. If so, explain why and draft an integration plan identifying endpoints to protect, bucket configurations, and fail-open vs. fail-closed routes using the package README and source code."
RateEngine uses @upstash/ratelimit under the hood to execute rate-limit checks using sliding window, fixed window, or token bucket algorithms. It adds a structured policy layer around those checks so application code can stay focused on request handling instead of repeated rate-limit orchestration.
| Feature | Raw @upstash/ratelimit |
With RateEngine |
|---|---|---|
| Chained Checks | Requires manually coordinating multiple limiter calls in route handlers. | 🔗 Policy Pipelines. Sequentially evaluates declared multi-stage policies, such as Global ➔ User ➔ Endpoint. |
| Fail-Safe Modes | Requires route-level error handling and custom fallback behavior. | ⚙️ Configurable. Define fail-open or fail-closed behavior at the policy level, or per direct bucket call. |
| Serverless Lifecycle | Requires handling result.pending when the runtime needs background work to stay alive. |
⚡ Handled. Passes background analytics promises to your environment's waitUntil hook when provided. |
| HTTP Responses | Returns raw metrics such as limit, remaining, and reset. |
🌐 Built-in Helpers. Generates rate-limit headers and standard 429 JSON responses. |
| Dynamic Routing | Requires custom route logic to switch between different limiter policies at runtime. | 🔄 Resolver Hook. Use resolvePolicy to redirect requests to stricter or alternative policies based on context. |
| Client Flexibility | Common usage is tied to @upstash/redis; TCP clients require adapter logic. |
🧩 Duck-Typed Redis Client. Accepts clients exposing the Redis command methods RateEngine needs. |
| Violation Tracking | Requires adding telemetry calls in each blocked path. | 🛡️ Violation Hook. Centralize logging, telemetry, or abuse tracking through onViolation. |
| Memory Optimization | Each Ratelimit instance may use its own cache unless a shared map is passed manually. |
🧠 Shared Cache. Shares one in-memory cache map across bucket limiters by default. |
Install RateEngine via your preferred package manager:
# npm
npm install rate-engine
# yarn
yarn add rate-engine
# pnpm
pnpm add rate-engine
# bun
bun add rate-engineCreate a buckets.ts file to define your rate-limit windows and capacities:
// buckets.ts
import { type BucketConfig } from "rate-engine";
export const APP_BUCKETS = {
"global:ip": {
requests: 500,
window: "1 m",
},
"global:user": {
requests: 300,
window: "1 m",
algorithm: "slidingWindow",
},
"auth:login": {
requests: 5,
window: "15 m",
algorithm: "fixedWindow",
},
"api:default": {
requests: 100,
window: "1 m",
},
"api:burst": {
requests: 50,
window: "10 s",
algorithm: "tokenBucket",
refillRate: 5,
},
} as const satisfies Record<string, BucketConfig>;
export type AppBucketId = keyof typeof APP_BUCKETS;Create a policies.ts file to define your multi-stage checking pipelines:
// policies.ts
import { type RateLimitPolicy } from "rate-engine";
import { type AppBucketId } from "./buckets";
export type AppContext = {
userId?: string;
ipAddress?: string;
userAgent?: string;
};
export const APP_POLICIES = {
"auth.login": {
// Critical endpoint: fail closed if Redis is degraded.
failureMode: "closed",
stages: [
{
bucketId: "global:ip",
identifier: (ctx) => ctx.ipAddress,
tier: "global",
message: "Too many requests from this IP.",
},
{
bucketId: "auth:login",
identifier: (ctx) => ctx.userId ?? ctx.ipAddress,
tier: "endpoint",
message: "Too many login attempts. Please try again later.",
},
],
},
"api.read": {
// Lower-risk endpoint: fail open to avoid unnecessary site outages.
failureMode: "open",
stages: [
{
bucketId: "api:default",
identifier: (ctx) => ctx.userId ?? ctx.ipAddress,
tier: "single",
},
],
},
} as const satisfies Record<string, RateLimitPolicy<AppBucketId, AppContext>>;
export type AppPolicyId = keyof typeof APP_POLICIES;Create a rate-engine.ts file to initialize the RateEngine instance:
// rate-engine.ts
import { Redis } from "@upstash/redis";
import { RateEngine } from "rate-engine";
import { APP_BUCKETS, type AppBucketId } from "./buckets";
import { APP_POLICIES, type AppContext, type AppPolicyId } from "./policies";
export const rateEngine = new RateEngine<AppPolicyId, AppBucketId, AppContext>({
redis: new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
}),
logger: console,
buckets: APP_BUCKETS,
policies: APP_POLICIES,
// Optional dynamic policy resolution.
resolvePolicy: async (policyId, context) => {
// Example: return a stricter policy for suspicious users.
return policyId;
},
// Optional central violation hook.
onViolation: async (context, decision) => {
console.warn(
`[RateEngine] ${context.ipAddress ?? "unknown"} exceeded ${decision.bucketId}`,
{
policyId: decision.policyId,
tier: decision.tier,
degraded: decision.degraded,
},
);
},
});Use enforce() inside your route handler. For serverless or edge runtimes, pass waitUntil so background analytics work can be completed by the platform.
// Next.js Route Handler Example
import { type NextRequest, NextResponse } from "next/server";
import { getRateLimitHeaders, toRateLimitResponse } from "rate-engine";
import { rateEngine } from "@/lib/rate-engine";
export async function POST(req: NextRequest, event: { waitUntil: any }) {
const ipAddress =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "127.0.0.1";
const decision = await rateEngine.enforce(
"auth.login",
{
ipAddress,
userId: "user_123",
userAgent: req.headers.get("user-agent") ?? undefined,
},
{
waitUntil: (promise) => event.waitUntil(promise),
},
);
if (!decision.allowed) {
return toRateLimitResponse(decision, {
message: "Too many login attempts. Please try again later.",
errorCode: "LOGIN_LIMIT_EXCEEDED",
});
}
const headers = getRateLimitHeaders(decision);
// Continue with authentication...
return NextResponse.json({ success: true }, { headers });
}A bucket defines a rate-limit capacity, window, and algorithm.
{
requests: 100,
window: "1 m",
algorithm: "slidingWindow"
}Supported algorithms:
slidingWindowfixedWindowtokenBucket
For token buckets, you can also provide refillRate.
A policy is an ordered list of stages. Each stage chooses a bucket and resolves the identifier to rate limit.
{
failureMode: "closed",
stages: [
{
bucketId: "global:ip",
identifier: (ctx) => ctx.ipAddress,
tier: "global",
},
{
bucketId: "auth:login",
identifier: (ctx) => ctx.userId ?? ctx.ipAddress,
tier: "endpoint",
},
],
}Policies are evaluated sequentially and stop at the first blocked stage.
RateEngine supports two fallback modes when Redis is unavailable or a rate-limit operation fails:
| Mode | Behavior | Common use |
|---|---|---|
open |
Allows the request during backend degradation. | Public reads, low-risk APIs, availability-first routes. |
closed |
Blocks the request during backend degradation. | Login, password reset, checkout, OTP, write-heavy or abuse-sensitive routes. |
enforce() uses the policy failure mode. Direct consumeBucket() calls default to fail open unless you pass failureMode: "closed".
For multi-stage policies, RateEngine returns a conservative root-level decision optimized for HTTP headers.
If all stages pass:
remainingcomes from the evaluated stage with the lowest remaining count.limitcomes from that same lowest-remaining stage.resetcomes from the stage with the latest reset timestamp.stagescontains the per-stage decisions.effectiveidentifies which buckets contributed the root-levellimit,remaining, andresetvalues.
This means root limit, remaining, and reset fields can be a composite of multiple stages. They are intended for conservative client-facing headers, not as a replacement for exact per-bucket state. Use decision.stages when you need exact per-stage quota state.
RateEngine evaluates policy stages sequentially and short-circuits on the first violation. This avoids downstream token consumption when an earlier stage already blocks the request.
For example, if a request is already blocked by a global IP limit, RateEngine will not also consume from the endpoint-specific bucket.
Tip
Each additional stage may add one Redis rate-limit operation. Keep latency-sensitive policies concise, and reserve longer pipelines for routes where the added precision is worth the extra round trips.
RateEngine is client-agnostic. It does not require a strict @upstash/redis instance; instead, it uses a duck-typed Redis client interface.
RateEngine is designed to work with Redis-compatible clients that expose the command methods required by @upstash/ratelimit, including:
evalevalshaincrexpireping
It has been designed for use with:
- ☁️ Cloud/enterprise managed Redis: AWS ElastiCache, Redis Cloud, Google Memorystore, and Azure Managed Redis through TCP clients such as
ioredisorredis. - ⚡ Serverless/edge Redis: Upstash Redis through HTTP REST using
@upstash/redis. - 🚀 Redis-compatible engines: DragonflyDB, KeyDB, and Valkey.
Provider-specific behavior should be verified in your deployment environment.
Note
Flipped environments: The usual local-vs-production setup can be reversed. If you self-host staging/production with DragonflyDB, Valkey, or ElastiCache and use Upstash for local development tunnels, prefer an explicit variable such as REDIS_PROVIDER=upstash|dragonfly|valkey instead of relying only on NODE_ENV.
The following proxy normalizes eval and evalsha calls between ioredis and @upstash/redis.
import { Redis as UpstashRedis } from "@upstash/redis";
import IORedis from "ioredis";
const provider = process.env.REDIS_PROVIDER ?? "upstash";
let client: UpstashRedis | IORedis | null = null;
function getRedisClient() {
if (client) return client;
if (provider === "tcp") {
client = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
} else {
client = new UpstashRedis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
}
return client;
}
export const redis = new Proxy({} as any, {
get(_target, prop) {
const activeClient = getRedisClient();
if (prop === "eval" || prop === "evalsha") {
return async (scriptOrSha: string, keys: string[], args: any[] = []) => {
if (provider === "tcp") {
return await (activeClient as IORedis)[prop](
scriptOrSha,
keys.length,
...keys,
...args,
);
}
return await (activeClient as UpstashRedis)[prop](
scriptOrSha,
keys,
args,
);
};
}
const value = (activeClient as any)[prop];
return typeof value === "function" ? value.bind(activeClient) : value;
},
});The RateEngine class is initialized with an options object.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
redis |
RateEngineRedisClient |
Yes | - | A duck-typed Redis client instance. |
buckets |
Record<TBucketId, BucketConfig> |
Yes | - | Configuration for all available rate-limit buckets. |
policies |
Record<TPolicyId, RateLimitPolicy> |
Yes | - | Named policies specifying ordered evaluation stages. |
logger |
RateEngineLogger |
No | - | Logger interface for rate-limit errors and background analytics failures. |
redisTimeoutMs |
number |
No | 1000 |
Redis response timeout before fallback behavior is triggered. |
fallbackResetMs |
number |
No | 60000 |
Reset duration used in degraded fallback snapshots. |
analytics |
boolean |
No | true |
Enables @upstash/ratelimit analytics uploads. Set to false to opt out; local health counters are never uploaded by RateEngine. |
bucketPrefixOverrides |
Partial<Record<TBucketId, string>> |
No | - | Optional per-bucket Redis key prefix overrides. |
resolvePolicy |
(policyId, context) => Promise<TPolicyId> | TPolicyId |
No | - | Hook for dynamically redirecting a request to another policy. |
ephemeralCache |
Map<string, number> |
No | Shared Map |
Optional custom shared cache map. |
onViolation |
(context, decision) => Promise<void> | void |
No | - | Callback triggered when a rate limit is breached or a fail-closed policy blocks due to degradation. |
Sequentially evaluates the stages of a named policy.
const decision = await rateEngine.enforce("auth.login", {
ipAddress: "203.0.113.10",
userId: "user_123",
});-
Arguments
policyId(TPolicyId): The policy ID to enforce.context(TContext): Request context used by stage identifier functions.options(EnforceOptions): Optional hooks such as{ waitUntil: (promise) => void }.
-
Returns
Promise<RateLimitDecision>
Returned decisions may include:
type RateLimitDecision = {
allowed: boolean;
bucketId: string;
identifier: string;
limit: number;
remaining: number;
used: number;
reset: number;
resetDate: Date;
degraded: boolean;
policyId?: string;
tier: "single" | "global" | "category" | "endpoint";
message?: string;
stages?: RateLimitStageDecision[];
effective?: EffectiveQuotaMeta;
};stages contains per-stage decision snapshots. effective identifies which stage supplied the root limit, remaining, and reset values.
Tip
In multi-stage policies, root quota fields are optimized for conservative client-facing headers. For exact per-stage state, inspect decision.stages.
Consumes a token from one bucket without running a full policy pipeline.
const decision = await rateEngine.consumeBucket("api:default", "user_123", {
failureMode: "closed",
});-
Arguments
bucketId(TBucketId): The target bucket ID.identifier(string): Unique actor identifier, such as an IP, user ID, or API key.options(ConsumeBucketOptions): Options such as{ rate, context, tier, policyId, message, failureMode }.enforceOptions(EnforceOptions): Optional hooks such as{ waitUntil }.
-
Returns
Promise<RateLimitDecision>
Warning
Direct calls to consumeBucket() bypass policy-level pipeline checks. Direct bucket consumption defaults to fail open on Redis errors. Pass failureMode: "closed" for sensitive direct bucket checks.
Reads the current state of a bucket without consuming a token.
const snapshot = await rateEngine.readBucket("api:default", "user_123");- Returns
Promise<RateLimitSnapshot>
Resets the consumed tokens for a bucket and identifier.
await rateEngine.resetBucket("auth:login", "user_123");A successful reset also records Redis connectivity as healthy for the stateful health tracker.
- Returns
Promise<void>
Pings Redis and returns stateful health telemetry.
const health = await rateEngine.getHealth();- Returns
Promise<{
healthy: boolean;
usingFallback: boolean;
consecutiveFailures: number;
totalFailures: number;
lastFailure: Date | null;
lastSuccess: Date | null;
}>;Note
Health telemetry is stored in memory on the current RateEngine instance. In serverless, edge, or horizontally scaled deployments, counters reflect only the current runtime instance, not global Redis health.
Clears stateful health telemetry.
rateEngine.resetHealth();This resets:
consecutiveFailurestotalFailureslastFailurelastSuccess
Useful for tests, administrative resets, or long-running processes that want to clear historical health counters after recovery.
- Returns
void
RateEngine includes framework-agnostic helpers for returning rate-limit status to clients.
Creates a standard 429 Too Many Requests JSON response.
return toRateLimitResponse(decision, {
message: "Too many requests. Please try again later.",
errorCode: "RATE_LIMIT_EXCEEDED",
});Response body:
{
"error": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Please try again later.",
"retryAfter": 60,
"degraded": false
}Creates an OAuth-style slow_down response for polling and device-flow endpoints.
return toOAuthSlowDownResponse(decision, {
message: "Polling too frequently. Please wait before trying again.",
});Response body:
{
"error": "slow_down",
"error_description": "Polling too frequently. Please wait before trying again.",
"retry_after": 60,
"degraded": false
}Builds rate-limit headers from a decision.
const headers = getRateLimitHeaders(decision);Use it on successful responses when clients need quota metadata before they hit a limit:
import { getRateLimitHeaders, toRateLimitResponse } from "rate-engine";
const decision = await rateEngine.enforce("translate.request", {
apiKeyId: "key_123",
});
if (!decision.allowed) {
return toRateLimitResponse(decision);
}
const result = await translate(request);
return Response.json(result, {
headers: getRateLimitHeaders(decision),
});Returned headers include:
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 60To build the package and generate TypeScript declarations:
bun run buildTo run the package unit tests:
bun run testTo run the package type check:
bun run typecheckAfter building, verify the published runtime exports:
bun run test:smokeboundary-enginefor safe HTTP route boundaries.redact-enginefor sensitive data redaction.secret-enginefor context-bound encryption and secret handling.session-enginefor browser session and cache lifecycle management.
MIT © Christian Paul
