Skip to content

Repository files navigation

RateEngine Banner

RateEngine: Rate Limiting Engine for TypeScript

TypeScriptRate LimitingPipelinesRedisLicenseChangelog

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.

🤖 Ask your coding assistant

"Audit my codebase to see if adding the rate-engine package 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."


Why use RateEngine?

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.

FeatureRaw @upstash/ratelimitWith RateEngine
Chained ChecksRequires manually coordinating multiple limiter calls in route handlers.🔗 Policy Pipelines. Sequentially evaluates declared multi-stage policies, such as Global ➔ User ➔ Endpoint.
Fail-Safe ModesRequires 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 LifecycleRequires 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 ResponsesReturns raw metrics such as limit, remaining, and reset.🌐 Built-in Helpers. Generates rate-limit headers and standard 429 JSON responses.
Dynamic RoutingRequires 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 FlexibilityCommon 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 TrackingRequires adding telemetry calls in each blocked path.🛡️ Violation Hook. Centralize logging, telemetry, or abuse tracking through onViolation.
Memory OptimizationEach 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.

Installation

Install RateEngine via your preferred package manager:

# npm
npm install rate-engine
# pnpm
pnpm add rate-engine
# bun
bun add rate-engine
# yarn
yarn add rate-engine

Getting Started

1. Define buckets

Create a buckets.ts file to define your rate-limit windows and capacities:

// buckets.tsimport{typeBucketConfig}from"rate-engine";exportconstAPP_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,},}asconstsatisfiesRecord<string,BucketConfig>;exporttypeAppBucketId=keyoftypeofAPP_BUCKETS;

2. Define policies

Create a policies.ts file to define your multi-stage checking pipelines:

// policies.tsimport{typeRateLimitPolicy}from"rate-engine";import{typeAppBucketId}from"./buckets";exporttypeAppContext={userId?: string;ipAddress?: string;userAgent?: string;};exportconstAPP_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",},],},}asconstsatisfiesRecord<string,RateLimitPolicy<AppBucketId,AppContext>>;exporttypeAppPolicyId=keyoftypeofAPP_POLICIES;

3. Instantiate RateEngine

Create a rate-engine.ts file to initialize the RateEngine instance:

// rate-engine.tsimport{Redis}from"@upstash/redis";import{RateEngine}from"rate-engine";import{APP_BUCKETS,typeAppBucketId}from"./buckets";import{APP_POLICIES,typeAppContext,typeAppPolicyId}from"./policies";exportconstrateEngine=newRateEngine<AppPolicyId,AppBucketId,AppContext>({redis: newRedis({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.returnpolicyId;},// 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,},);},});

4. Enforce limits in an API handler

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 Exampleimport{typeNextRequest,NextResponse}from"next/server";import{getRateLimitHeaders,toRateLimitResponse}from"rate-engine";import{rateEngine}from"@/lib/rate-engine";exportasyncfunctionPOST(req: NextRequest,event: {waitUntil: any}){constipAddress=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()??"127.0.0.1";constdecision=awaitrateEngine.enforce("auth.login",{
ipAddress,userId: "user_123",userAgent: req.headers.get("user-agent")??undefined,},{waitUntil: (promise)=>event.waitUntil(promise),},);if(!decision.allowed){returntoRateLimitResponse(decision,{message: "Too many login attempts. Please try again later.",errorCode: "LOGIN_LIMIT_EXCEEDED",});}constheaders=getRateLimitHeaders(decision);// Continue with authentication...returnNextResponse.json({success: true},{ headers });}

Core Concepts

Buckets

A bucket defines a rate-limit capacity, window, and algorithm.

{requests: 100,window: "1 m",algorithm: "slidingWindow"}

Supported algorithms:

  • slidingWindow
  • fixedWindow
  • tokenBucket

For token buckets, you can also provide refillRate.

Policies

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.

Failure modes

RateEngine supports two fallback modes when Redis is unavailable or a rate-limit operation fails:

ModeBehaviorCommon use
openAllows the request during backend degradation.Public reads, low-risk APIs, availability-first routes.
closedBlocks 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".

Effective quota reporting

For multi-stage policies, RateEngine returns a conservative root-level decision optimized for HTTP headers.

If all stages pass:

  • remaining comes from the evaluated stage with the lowest remaining count.
  • limit comes from that same lowest-remaining stage.
  • reset comes from the stage with the latest reset timestamp.
  • stages contains the per-stage decisions.
  • effective identifies which buckets contributed the root-level limit, remaining, and reset values.

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.

Sequential execution

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.


Redis Client Compatibility

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:

  • eval
  • evalsha
  • incr
  • expire
  • ping

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 ioredis or redis.
  • 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.

Advanced: Environment-aware Redis proxy

The following proxy normalizes eval and evalsha calls between ioredis and @upstash/redis.

import{RedisasUpstashRedis}from"@upstash/redis";importIORedisfrom"ioredis";constprovider=process.env.REDIS_PROVIDER??"upstash";letclient: UpstashRedis|IORedis|null=null;functiongetRedisClient(){if(client)returnclient;if(provider==="tcp"){client=newIORedis(process.env.REDIS_URL??"redis://localhost:6379");}else{client=newUpstashRedis({url: process.env.UPSTASH_REDIS_REST_URL!,token: process.env.UPSTASH_REDIS_REST_TOKEN!,});}returnclient;}exportconstredis=newProxy({}asany,{get(_target,prop){constactiveClient=getRedisClient();if(prop==="eval"||prop==="evalsha"){returnasync(scriptOrSha: string,keys: string[],args: any[]=[])=>{if(provider==="tcp"){returnawait(activeClientasIORedis)[prop](scriptOrSha,keys.length,
...keys,
...args,);}returnawait(activeClientasUpstashRedis)[prop](scriptOrSha,keys,args,);};}constvalue=(activeClientasany)[prop];returntypeofvalue==="function" ? value.bind(activeClient) : value;},});

API Reference

RateEngine constructor

The RateEngine class is initialized with an options object.

ParameterTypeRequiredDefaultDescription
redisRateEngineRedisClientYes-A duck-typed Redis client instance.
bucketsRecord<TBucketId, BucketConfig>Yes-Configuration for all available rate-limit buckets.
policiesRecord<TPolicyId, RateLimitPolicy>Yes-Named policies specifying ordered evaluation stages.
loggerRateEngineLoggerNo-Logger interface for rate-limit errors and background analytics failures.
redisTimeoutMsnumberNo1000Redis response timeout before fallback behavior is triggered.
fallbackResetMsnumberNo60000Reset duration used in degraded fallback snapshots.
analyticsbooleanNotrueEnables @upstash/ratelimit analytics uploads. Set to false to opt out; local health counters are never uploaded by RateEngine.
bucketPrefixOverridesPartial<Record<TBucketId, string>>No-Optional per-bucket Redis key prefix overrides.
resolvePolicy(policyId, context) => Promise<TPolicyId> | TPolicyIdNo-Hook for dynamically redirecting a request to another policy.
ephemeralCacheMap<string, number>NoShared MapOptional custom shared cache map.
onViolation(context, decision) => Promise<void> | voidNo-Callback triggered when a rate limit is breached or a fail-closed policy blocks due to degradation.

Dynamic policy resolution

Use resolvePolicy when the active policy depends on request context, such as an account tier or an application-managed cooldown:

constrateEngine=newRateEngine({
redis,
buckets,
policies,resolvePolicy: (policyId,context)=>{if(policyId==="api.read"&&context.plan==="pro"){return"api.read.pro";}returnpolicyId;},});

The returned ID must exist in policies. Resolution changes which policy is evaluated; it does not merge the original and resolved policy.

RateEngine validates static configuration during construction. Invalid bucket capacities, durations or policies, incompatible refill settings, unknown static bucket references, and invalid timeout values fail before requests are handled. Dynamic bucket resolvers remain validated when they are used.


Instance methods

1. enforce(policyId, context, options?)

Sequentially evaluates the stages of a named policy.

constdecision=awaitrateEngine.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:

typeRateLimitDecision={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.


2. consumeBucket(bucketId, identifier, options?, enforceOptions?)

Consumes a token from one bucket without running a full policy pipeline.

constdecision=awaitrateEngine.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.

Identifiers are trimmed and must contain at most 512 UTF-8 bytes. Choose a stable identifier derived from a trusted source; validation limits key size but cannot make attacker-controlled, high-cardinality identifiers stable. Custom rate values must be positive safe integers.


3. readBucket(bucketId, identifier)

Reads the current state of a bucket without consuming a token.

constsnapshot=awaitrateEngine.readBucket("api:default","user_123");
  • Returns
    • Promise<RateLimitSnapshot>

4. readBuckets(entries)

Reads multiple bucket states concurrently without consuming tokens. Results remain in the same order as the input entries.

constsnapshots=awaitrateEngine.readBuckets([{bucketId: "global:user",identifier: "user_123"},{bucketId: "api:default",identifier: "user_123"},]);
  • Returns
    • Promise<RateLimitSnapshot[]>

5. resetBucket(bucketId, identifier)

Resets the consumed tokens for a bucket and identifier.

constresult=awaitrateEngine.resetBucket("auth:login","user_123");if(!result.ok){// The reset did not reach Redis.}

A successful reset also records Redis connectivity as healthy for the stateful health tracker.

  • Returns
    • Promise<ResetBucketResult>

ResetBucketResult is { ok: true } on success or { ok: false, reason: "backend_error" } when Redis rejects the reset.


6. pingRedis()

Performs a live Redis connectivity check and measures round-trip latency.

constping=awaitrateEngine.pingRedis();
  • Returns
Promise<{reachable: boolean;latencyMs: number;}>;

Use this method for health endpoints. It performs a network call on every invocation and records the result in the instance metrics.


7. getInstanceMetrics()

Returns the in-memory telemetry for the current RateEngine instance.

constmetrics=rateEngine.getInstanceMetrics();
  • Returns
{
consecutiveFailures: number;
totalFailures: number;
lastFailure: Date|null;
lastSuccess: Date|null;}

Note

These counters reset with the process. In serverless, edge, or horizontally scaled deployments, they do not represent aggregate Redis health.


8. resetHealth()

Clears stateful health telemetry.

rateEngine.resetHealth();

This resets:

  • consecutiveFailures
  • totalFailures
  • lastFailure
  • lastSuccess

Useful for tests, administrative resets, or long-running processes that want to clear historical health counters after recovery.

  • Returns
    • void

HTTP Adapters

RateEngine includes framework-agnostic helpers for returning rate-limit status to clients.

toRateLimitResponse(decision, options?)

Creates a standard 429 Too Many Requests JSON response.

returntoRateLimitResponse(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
}

toOAuthSlowDownResponse(decision, options?)

Creates an OAuth-style slow_down response for polling and device-flow endpoints.

returntoOAuthSlowDownResponse(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
}

getRateLimitHeaders(decision)

Builds rate-limit headers from a decision.

constheaders=getRateLimitHeaders(decision);

Use it on successful responses when clients need quota metadata before they hit a limit:

import{getRateLimitHeaders,toRateLimitResponse}from"rate-engine";constdecision=awaitrateEngine.enforce("translate.request",{apiKeyId: "key_123",});if(!decision.allowed){returntoRateLimitResponse(decision);}constresult=awaittranslate(request);returnResponse.json(result,{headers: getRateLimitHeaders(decision),});

Returned headers include:

RateLimit-Limit: 100RateLimit-Remaining: 42RateLimit-Reset: 60

RateLimit-Reset is the non-negative number of seconds until reset, matching the current IETF RateLimit field semantics. The underlying decision keeps reset as a Unix timestamp in milliseconds.


Development

To build the package and generate TypeScript declarations:

bun run build

To run the package unit tests:

bun run test

To run the package type check:

bun run typecheck

After building, verify the published runtime exports:

bun run test:smoke

Related Packages


License

MIT © Christian Paul

About

A client-agnostic, multi-stage rate limiting policy engine for TypeScript, powered by @upstash/ratelimit.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages