@sanskari27/aws-rate-limiter
v1.0.0
Published
Production-grade multi-dimensional rate limiter for AWS Lambda and EC2/ECS
Downloads
9
Maintainers
Readme
aws-rate-limiter
Production-grade, multi-dimensional sliding-window rate limiter for Node.js — built for AWS Lambda, EC2, and ECS. Backed by Redis/ElastiCache with atomic Lua scripts, an in-process token reservoir, circuit breaker, and first-class adapters for Express, Fastify, and Lambda.
Table of Contents
- How It Works
- Prerequisites
- Installation
- Quick Start
- Configuration
- Framework Adapters
- Rate Limit Rules
- Dimensions
- Failure Policies
- Reservoir (Performance)
- Response Headers
- API Reference
- Error Handling
- Infrastructure
- Examples
How It Works
Every incoming request is checked against up to four dimensions in order — the first one to exceed its limit denies the request immediately (fail-fast):
Request → [1] Per-IP → [2] Per-route → [3] Per-user → [4] Per-user+route → AllowedEach dimension uses a sliding window counter stored in Redis via atomic Lua scripts (EVALSHA). An optional in-process token reservoir pre-fetches tokens in batches, reducing Redis round-trips by up to 100×. A circuit breaker isolates your app from Redis failures, falling back to configurable policies (open / closed / local).
Your App
│
├── Express / Fastify middleware ──┐
│ ▼
├── Lambda Extension + decorator Rate Limiter Core
│ │
└── Direct RateLimiter API ────────┤
│ ┌──────────────────┐
├──▶│ Local Reservoir │ (in-process, ~0ms)
│ └──────────────────┘
│ ┌──────────────────┐
└──▶│ Redis EVALSHA │ (~0.5ms same-AZ)
└──────────────────┘Prerequisites
| Requirement | Version | Notes | |-------------|---------|-------| | Node.js | ≥ 18.x | Required | | Redis | ≥ 6.x | Single node or cluster | | TypeScript | ≥ 5.x | Optional, types included |
Redis options:
- Local development — Docker:
docker run -d -p 6379:6379 redis:7-alpine - AWS production — ElastiCache for Redis (single node or cluster mode)
- Cluster mode — Supported natively; pass
redis.clusterinstead ofredis.url
Installation
npm install aws-rate-limiterNo peer dependencies required. Express, Fastify, Koa, etc. are optional — only install what your project already uses.
Quick Start
import { RateLimiter } from 'aws-rate-limiter';
const limiter = new RateLimiter({
redis: { url: 'redis://localhost:6379' },
rules: [
{
name: 'default',
limits: {
ip: { limit: 100, window: 60 }, // 100 requests / IP / minute
user: { limit: 200, window: 60 }, // 200 requests / user / minute
},
},
],
});
await limiter.connect();
const result = await limiter.check({
ip: '1.2.3.4',
userId: 'user-123',
route: '/api/users',
method: 'GET',
});
if (!result.allowed) {
// 429 Too Many Requests
console.log(`Retry after ${result.retryAfter}ms`);
}
await limiter.shutdown();Configuration
Programmatic Config
Pass a RateLimiterConfig object directly to the constructor:
import { RateLimiter, RateLimiterConfig } from 'aws-rate-limiter';
const config: RateLimiterConfig = {
// ── Redis connection (required) ───────────────────────────────────────
redis: {
url: 'redis://localhost:6379', // Single node
// or for cluster:
// cluster: { nodes: [{ host: 'node1', port: 6379 }, { host: 'node2', port: 6379 }] }
password: process.env.REDIS_AUTH,
connectTimeout: 500, // ms — default: 200
commandTimeout: 200, // ms — default: 100
maxRetriesPerRequest: 2, // default: 1
},
// ── Rules (required, at least one) ────────────────────────────────────
rules: [
{
name: 'default',
limits: {
ip: { limit: 100, window: 60 },
route: { limit: 1000, window: 60 },
user: { limit: 200, window: 60 },
userRoute: { limit: 50, window: 60 },
},
},
],
// ── Reservoir (optional — improves performance) ────────────────────────
reservoir: {
enabled: true,
batchSize: 10, // tokens to pre-fetch per batch
syncInterval: 1000, // ms between background syncs
},
// ── Failure policy (optional) ──────────────────────────────────────────
failure: {
default: 'open', // 'open' | 'closed' | 'local'
circuitBreaker: {
enabled: true,
threshold: 5, // consecutive failures before opening
recoveryTimeout: 30000, // ms before attempting recovery
},
},
// ── Observability (optional) ───────────────────────────────────────────
observability: {
logLevel: 'info', // 'debug' | 'info' | 'warn' | 'error'
logSampleRate: 0.1, // fraction of allowed requests to log
metrics: 'none', // 'none' | 'memory' | 'cloudwatch'
namespace: 'MyApp', // CloudWatch/Prometheus namespace
},
};
const limiter = new RateLimiter(config);YAML File Config
Create a rate-limiter.yaml file (supports ${ENV_VAR} substitution):
redis:
url: ${REDIS_URL}
password: ${REDIS_AUTH}
connectTimeout: 500
commandTimeout: 200
rules:
- name: auth-endpoints
match:
routes:
- "POST /auth/*"
- "POST /login"
limits:
ip:
limit: 10
window: 60
failure: closed
- name: default
limits:
ip:
limit: 100
window: 60
user:
limit: 200
window: 60
failure:
default: open
circuitBreaker:
enabled: true
threshold: 5
recoveryTimeout: 30000Load it in your application:
import { loadConfig } from 'aws-rate-limiter/config';
const config = loadConfig('./rate-limiter.yaml');
const limiter = new RateLimiter(config);loadConfig(path?) resolution order:
- Explicit
pathargument RATE_LIMITER_CONFIGenvironment variable- Falls back to
loadConfigFromEnv()if no file is found
Environment Variable Config
For simple deployments without a YAML file:
| Variable | Config Field | Default |
|----------|-------------|---------|
| RATE_LIMITER_REDIS_URL | redis.url | redis://localhost:6379 |
| RATE_LIMITER_REDIS_AUTH | redis.password | — |
| RATE_LIMITER_DEFAULT_LIMIT | default rule ip.limit | 60 |
| RATE_LIMITER_DEFAULT_WINDOW | default rule ip.window (seconds) | 60 |
| RATE_LIMITER_FAILURE_POLICY | failure.default | open |
| RATE_LIMITER_RESERVOIR_ENABLED | reservoir.enabled | false |
| RATE_LIMITER_RESERVOIR_BATCH_SIZE | reservoir.batchSize | 10 |
| RATE_LIMITER_RESERVOIR_SYNC_INTERVAL | reservoir.syncInterval (ms) | 1000 |
| RATE_LIMITER_CIRCUIT_BREAKER_ENABLED | circuit breaker on/off | false |
| RATE_LIMITER_LOG_LEVEL | observability.logLevel | info |
| RATE_LIMITER_LOG_SAMPLE_RATE | observability.logSampleRate | 1 |
| RATE_LIMITER_METRICS_BACKEND | observability.metrics | none |
| RATE_LIMITER_METRICS_NAMESPACE | observability.namespace | — |
RATE_LIMITER_REDIS_URL=redis://my-elasticache:6379 \
RATE_LIMITER_DEFAULT_LIMIT=100 \
RATE_LIMITER_DEFAULT_WINDOW=60 \
node dist/server.jsThen load it:
import { loadConfigFromEnv } from 'aws-rate-limiter/config';
const limiter = new RateLimiter(loadConfigFromEnv());Framework Adapters
Express
Built-in adapter — no additional dependencies:
import express from 'express';
import { RateLimiter } from 'aws-rate-limiter';
import { createExpressMiddleware } from 'aws-rate-limiter/adapters/express';
const limiter = new RateLimiter({ /* config */ });
await limiter.connect();
const app = express();
app.set('trust proxy', 1);
app.use(
createExpressMiddleware({
rateLimiter: limiter,
// Routes to skip entirely (minimatch globs)
skipRoutes: ['/health', '/metrics', '/internal/*'],
// Whether to attach X-RateLimit-* headers on allowed responses (default: true)
setHeaders: true,
// How to extract the real client IP
ipExtraction: {
trustXForwardedFor: true,
trustedProxyCount: 1,
},
// Return the user's tier for rule matching (e.g. 'free', 'premium', 'enterprise')
// Without this, rules with match.userTiers will never match.
getUserTier: (req) => {
// Option A: header injected by API gateway / auth proxy
const tier = req.headers['x-user-tier'];
if (tier) return tier as string;
// Option B: from decoded JWT attached by earlier auth middleware
return (req as any).user?.tier;
},
}),
);User identity is extracted automatically from:
Authorization: Bearer <token>header → treated asapiKeyX-API-Keyheader → treated asapiKey
To use userId instead, pass userExtraction.apiKeyHeader or populate req.user and provide a custom getUserTier.
Fastify
Built-in adapter — registers as a preHandler hook:
import Fastify from 'fastify';
import { RateLimiter } from 'aws-rate-limiter';
import { createFastifyHook } from 'aws-rate-limiter/adapters/fastify';
const limiter = new RateLimiter({ /* config */ });
await limiter.connect();
const fastify = Fastify({ trustProxy: true });
fastify.addHook(
'preHandler',
createFastifyHook({
rateLimiter: limiter,
skipRoutes: ['/health', '/metrics'],
ipExtraction: { trustXForwardedFor: true, trustedProxyCount: 1 },
getUserTier: (req) => req.headers['x-user-tier'] as string | undefined,
}),
);Rate limit headers (X-RateLimit-*) are always set — even on denied requests — so clients can adapt.
AWS Lambda
Lambda uses a two-component pattern to keep a persistent Redis connection alive across invocations:
1. Extension (Lambda Layer) — extension.ts
import { LambdaExtension } from 'aws-rate-limiter/adapters/lambda';
const extension = new LambdaExtension({
rateLimiterConfig: {
redis: { url: process.env.REDIS_URL! },
rules: [
{
name: 'default',
limits: { ip: { limit: 100, window: 60 }, user: { limit: 200, window: 60 } },
},
],
},
port: 2772, // default — must match extensionUrl in decorator
});
// Blocks until Lambda sends SHUTDOWN event
await extension.run();2. Handler Decorator — handler.ts
import { withRateLimit } from 'aws-rate-limiter/adapters/lambda';
export const handler = withRateLimit(
async (event, context) => {
return { statusCode: 200, body: JSON.stringify({ message: 'Hello' }) };
},
{
extensionUrl: 'http://localhost:2772', // default
userIdHeader: 'x-user-id',
apiKeyHeader: 'x-api-key',
trustedProxyCount: 1,
// Cloudfront or other custom IP headers
ipHeaders: ['cf-connecting-ip', 'true-client-ip'],
},
);Why Lambda needs an Extension: Without it, each Lambda invocation opens a new Redis connection (+200ms TLS handshake). The Extension keeps one persistent connection per container — warm invocations have zero connection overhead.
Fail-open by design: If the Extension is unreachable (e.g., still starting up), withRateLimit allows the request through rather than dropping traffic.
Koa
No built-in adapter — use the core RateLimiter API directly as middleware:
import Koa from 'koa';
import { RateLimiter } from 'aws-rate-limiter';
const limiter = new RateLimiter({ /* config */ });
await limiter.connect();
const app = new Koa();
app.proxy = true;
app.use(async (ctx, next) => {
if (ctx.path === '/health') return next();
const result = await limiter.check({
ip: ctx.ip,
apiKey: ctx.get('x-api-key') || undefined,
route: ctx.path,
method: ctx.method,
userTier: ctx.get('x-user-tier') || undefined,
});
const windowSecs = result.windowSecs ?? 60;
ctx.set('X-RateLimit-Limit', String(result.limit));
ctx.set('X-RateLimit-Remaining', String(Math.max(0, result.remaining)));
ctx.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
ctx.set('X-RateLimit-Policy', `${result.limit};w=${windowSecs}`);
if (!result.allowed) {
if (result.retryAfter !== undefined) {
ctx.set('Retry-After', String(Math.ceil(result.retryAfter / 1000)));
}
ctx.status = 429;
ctx.body = { error: 'Too Many Requests', retryAfter: result.retryAfter };
return;
}
await next();
});NestJS
Two patterns — choose based on your needs:
Pattern 1: Global middleware (simplest — wraps the Express adapter):
import { Injectable, NestMiddleware, Module } from '@nestjs/common';
import { createExpressMiddleware } from 'aws-rate-limiter/adapters/express';
import type { Request, Response, NextFunction } from 'express';
@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
private readonly middleware = createExpressMiddleware({
rateLimiter: globalLimiterInstance,
skipRoutes: ['/health'],
});
async use(req: Request, res: Response, next: NextFunction) {
await this.middleware(req as any, res as any, next);
}
}
@Module({})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RateLimitMiddleware).forRoutes('*');
}
}Pattern 2: Guard (per-controller or per-route with @UseGuards):
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { RateLimiter } from 'aws-rate-limiter';
@Injectable()
export class RateLimitGuard implements CanActivate {
constructor(private readonly limiter: RateLimiter) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const res = context.switchToHttp().getResponse();
const result = await this.limiter.check({
ip: req.ip,
apiKey: req.headers['x-api-key'],
route: req.path,
method: req.method,
});
res.set('X-RateLimit-Remaining', String(Math.max(0, result.remaining)));
if (!result.allowed) {
throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
}
return true;
}
}Register RateLimiter as a provider with onModuleInit / onModuleDestroy for proper lifecycle management. See examples/nestjs.ts for the full wiring.
Hapi
Use the onPreHandler lifecycle extension:
import Hapi from '@hapi/hapi';
import { RateLimiter } from 'aws-rate-limiter';
const limiter = new RateLimiter({ /* config */ });
await limiter.connect();
const server = Hapi.server({ port: 3000 });
server.ext('onPreHandler', async (request, h) => {
if (request.path === '/health') return h.continue;
const ip = request.headers['x-forwarded-for']?.split(',')[0]?.trim()
|| request.info.remoteAddress;
const result = await limiter.check({
ip: ip || '0.0.0.0',
apiKey: request.headers['x-api-key'],
route: request.path,
method: request.method.toUpperCase(),
});
if (!result.allowed) {
return h.response({ error: 'Too Many Requests' }).code(429).takeover();
}
return h.continue;
});Plain Node.js HTTP
import * as http from 'http';
import { RateLimiter } from 'aws-rate-limiter';
const limiter = new RateLimiter({ /* config */ });
await limiter.connect();
const server = http.createServer(async (req, res) => {
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim()
|| req.socket.remoteAddress
|| '0.0.0.0';
const result = await limiter.check({
ip,
apiKey: req.headers['x-api-key'] as string | undefined,
route: new URL(req.url!, 'http://localhost').pathname,
method: req.method || 'GET',
});
res.setHeader('X-RateLimit-Limit', String(result.limit));
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, result.remaining)));
res.setHeader('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
if (!result.allowed) {
res.writeHead(429, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Too Many Requests' }));
return;
}
// ... your handler
});Rate Limit Rules
Per-Route Limits
Rules are matched top-to-bottom — first match wins. Route patterns are matched against "METHOD /path" using minimatch globs.
const limiter = new RateLimiter({
redis: { url: 'redis://localhost:6379' },
rules: [
// Most specific rules first
{
name: 'auth',
match: { routes: ['POST /auth/*', 'POST /login', 'POST /register'] },
limits: { ip: { limit: 10, window: 60 } },
failure: 'closed', // deny all if Redis is down
},
{
name: 'uploads',
match: { routes: ['POST /api/uploads', 'PUT /api/files/*'] },
limits: {
ip: { limit: 20, window: 300 }, // 20 uploads per 5 min
user: { limit: 50, window: 300 },
},
},
{
name: 'search',
match: { routes: ['GET /api/search', 'GET /api/search/*'] },
limits: { ip: { limit: 30, window: 60 }, user: { limit: 60, window: 60 } },
},
{
name: 'read-apis',
match: { routes: ['GET /api/**'] }, // all GET routes under /api/
limits: { ip: { limit: 200, window: 60 }, user: { limit: 300, window: 60 } },
},
{
name: 'write-apis',
match: { routes: ['POST /api/**', 'PUT /api/**', 'PATCH /api/**', 'DELETE /api/**'] },
limits: {
ip: { limit: 50, window: 60 },
user: { limit: 100, window: 60 },
userRoute: { limit: 20, window: 60 },
},
},
// Catch-all must be last — no match criteria
{
name: 'default',
limits: { ip: { limit: 100, window: 60 }, user: { limit: 200, window: 60 } },
},
],
});Glob pattern reference:
| Pattern | Matches |
|---------|---------|
| "GET /api/users" | Exact route only |
| "GET /api/users/*" | One level deep: /api/users/123 |
| "GET /api/**" | Any depth: /api/users/123/posts |
| "POST /api/**" | All POST routes under /api/ |
| "* /api/search" | Any HTTP method to /api/search |
Per-Tier Limits
Use match.userTiers to apply different limits based on the authenticated user's subscription tier. Your adapter must supply the userTier via the getUserTier callback (Express/Fastify) or by setting ctx.userTier directly (core API). If userTier is undefined, tier rules are skipped.
rules: [
{
name: 'enterprise',
match: { userTiers: ['enterprise'] },
limits: { user: { limit: 10000, window: 60 }, userRoute: { limit: 1000, window: 60 } },
},
{
name: 'premium',
match: { userTiers: ['premium'] },
limits: { user: { limit: 1000, window: 60 }, userRoute: { limit: 100, window: 60 } },
},
{
name: 'default',
limits: { ip: { limit: 100, window: 60 }, user: { limit: 200, window: 60 } },
},
],Supply the tier in your adapter:
// Express
createExpressMiddleware({
rateLimiter: limiter,
getUserTier: (req) => (req as any).user?.subscription ?? req.headers['x-user-tier'] as string,
});
// Core API
await limiter.check({
ip: '1.2.3.4',
userId: 'user-123',
route: '/api/data',
method: 'GET',
userTier: 'premium', // <-- set this from your auth layer
});Rule Matching
Match conditions use AND semantics — all specified conditions must pass for a rule to match:
{
name: 'premium-writes',
match: {
routes: ['POST /api/**', 'PUT /api/**'], // must be a write route
userTiers: ['premium', 'enterprise'], // AND must be premium/enterprise
},
limits: { user: { limit: 500, window: 60 }, userRoute: { limit: 50, window: 60 } },
}A rule with no match field acts as a catch-all and matches every request.
Dimensions
Each rule defines limits per dimension. Only configured dimensions are checked; omitted ones are skipped.
| Dimension | Config Key | Redis Key Pattern | When Active |
|-----------|-----------|-------------------|-------------|
| Per-IP | limits.ip | rl:v1:ip:{ip}:{bucket} | Always |
| Per-route | limits.route | rl:v1:route:{method_path}:{bucket} | Always |
| Per-user | limits.user | rl:v1:user:{hash}:{bucket} | When userId or apiKey is present |
| Per-user+route | limits.userRoute | rl:v1:user-route:{hash}:{route}:{bucket} | When userId or apiKey is present |
User identifiers (userId, apiKey) are SHA-256 hashed before being stored in Redis keys — they are never stored in plain text.
The window value is in seconds. A window of 60 means a 60-second sliding window (not a fixed 1-minute bucket).
Failure Policies
Configure what happens when Redis is unavailable. Can be set globally and overridden per rule:
| Policy | Behaviour | Use For |
|--------|-----------|---------|
| open (default) | Allow all traffic — rate limiting is temporarily disabled | Public content, read-heavy APIs |
| closed | Deny all traffic with 429 | Auth endpoints, payment routes |
| local | In-process fixed-window fallback — rate limiting still works, but per-instance not globally | Medium-criticality APIs |
// Global default + per-rule overrides
{
failure: { default: 'open' },
rules: [
{ name: 'auth', match: { routes: ['POST /auth/*'] }, limits: {...}, failure: 'closed' },
{ name: 'payments', match: { routes: ['POST /pay/*'] }, limits: {...}, failure: 'closed' },
{ name: 'search', match: { routes: ['GET /search'] }, limits: {...}, failure: 'local' },
{ name: 'default', limits: {...} }, // inherits 'open' from global default
],
}Circuit breaker — prevents hammering a struggling Redis:
failure: {
default: 'open',
circuitBreaker: {
enabled: true,
threshold: 5, // open after 5 consecutive failures
recoveryTimeout: 30000, // try again after 30 seconds
},
}States: closed (normal) → open (failing, apply policy) → half-open (testing recovery) → closed.
Reservoir (Performance)
The local token reservoir pre-fetches tokens from Redis in batches, serving most requests from memory without a Redis round-trip:
Without reservoir: every request → Redis (~0.5ms each)
With reservoir: ~99% of requests → in-memory (~0.01ms), ~1% → Redis refillreservoir: {
enabled: true,
batchSize: 10, // tokens to claim per Redis call
syncInterval: 1000, // background sync interval (ms)
}Sizing trade-off: larger batchSize → fewer Redis calls but allows short bursts above the limit. Rule of thumb: batchSize ≤ limit / (expectedInstances × 10).
On shutdown(), unused tokens are returned to Redis. If shutdown is abrupt, tokens expire via Redis TTL (2× window length).
Response Headers
On every rate-limited response (both allowed and denied):
| Header | Value | Example |
|--------|-------|---------|
| X-RateLimit-Limit | Configured limit for the matched dimension | 100 |
| X-RateLimit-Remaining | Remaining requests in current window | 42 |
| X-RateLimit-Reset | Unix timestamp (seconds) when window resets | 1706000060 |
| X-RateLimit-Policy | Limit and window in RFC-8941 format | 100;w=60 |
| Retry-After | Seconds to wait before retrying (429 only) | 15 |
API Reference
new RateLimiter(config)
const limiter = new RateLimiter(config: RateLimiterConfig);Throws ConfigurationError if rules is empty or config is invalid.
limiter.connect()
await limiter.connect(): Promise<void>Connects to Redis and loads Lua scripts. Must be called before any other method. Throws RedisConnectionError on failure.
limiter.check(ctx)
await limiter.check(ctx: RateLimitContext): Promise<RateLimitResult>Evaluates all active dimensions and returns the result. Consumes quota — use status() for read-only queries.
// RateLimitContext
{
ip: string; // required — raw client IP (IPv4 or IPv6)
route: string; // required — request path e.g. "/api/users"
method: string; // required — HTTP method e.g. "GET"
userId?: string; // optional — authenticated user ID
apiKey?: string; // optional — API key (used if no userId)
userTier?: string; // optional — subscription tier for rule matching
cost?: number; // optional — request cost weight (default: 1)
}
// RateLimitResult
{
allowed: boolean; // true = proceed, false = 429
dimension: string; // which dimension denied ("ip" | "route" | "user" | "user-route" | "none")
limit: number; // configured limit for triggering dimension
remaining: number; // remaining quota (clamped to 0)
resetAt: number; // Unix epoch ms when window resets
windowSecs?: number; // window duration in seconds
retryAfter?: number; // ms to wait before retry (only on deny)
effective: number; // sliding-window effective request count
source: 'redis' | 'reservoir' | 'local_fallback';
}limiter.status(ctx)
await limiter.status(ctx: RateLimitContext): Promise<RateLimitResult>Read-only quota check — does not consume quota. Returns the most-constrained dimension. Useful for dashboards and pre-flight checks.
limiter.reset(dimension, identifier)
await limiter.reset(
dimension: 'ip' | 'user' | 'route' | 'user-route',
identifier: string,
): Promise<number> // number of Redis keys deletedDeletes rate limit counters for a specific dimension and identifier. Use for admin operations (e.g., unblock a user after a support ticket).
// Reset by IP
await limiter.reset('ip', '1.2.3.4');
// Reset a user (pass raw userId — it will be hashed internally)
await limiter.reset('user', 'user-abc-123');
// Reset a specific user+route combination
await limiter.reset('user-route', 'user-abc-123:GET /api/search');limiter.isConnected()
limiter.isConnected(): booleanlimiter.shutdown()
await limiter.shutdown(): Promise<void>Flushes the reservoir and disconnects from Redis. Always call on SIGTERM/SIGINT.
Error Handling
import {
RateLimiterError,
RedisConnectionError,
ConfigurationError,
} from 'aws-rate-limiter';
try {
await limiter.connect();
} catch (err) {
if (err instanceof RedisConnectionError) {
console.error('Cannot reach Redis:', err.message, err.code);
} else if (err instanceof ConfigurationError) {
console.error('Bad config:', err.message, err.code);
}
}
// check() does not throw on Redis failure — it applies the failure policy.
// It only throws ConfigurationError if connect() was not called first.
const result = await limiter.check(ctx);
// result.source === 'local_fallback' if Redis was unavailable| Error Class | code | When Thrown |
|-------------|--------|-------------|
| ConfigurationError | CONFIGURATION_ERROR | Invalid config, or check() called before connect() |
| RedisConnectionError | REDIS_CONNECTION_ERROR | Connection failure during connect() |
Infrastructure
Local Development
# Start Redis
docker run -d --name redis -p 6379:6379 redis:7-alpine
# Verify
redis-cli ping # → PONGAWS ElastiCache (Production)
Single node (dev/staging):
redis: { url: 'rediss://my-cluster.xxxxx.ng.0001.use1.cache.amazonaws.com:6379' }
// rediss:// enables TLS — always use in productionCluster mode (production):
redis: {
cluster: {
nodes: [
{ host: 'my-cluster.xxxxx.clustercfg.use1.cache.amazonaws.com', port: 6379 },
],
},
password: process.env.REDIS_AUTH_TOKEN,
}Recommended node types:
| Environment | Node Type | Notes |
|-------------|-----------|-------|
| Dev / Staging | cache.t3.medium | ~20K ops/s |
| Small production | cache.r7g.large | ~100K ops/s |
| High traffic | cache.r7g.xlarge | ~200K ops/s |
| 1M req/s | 6× cache.r7g.large cluster | ~600K ops/s with reservoir |
Security checklist:
- Always use TLS (
rediss://) - Set an AUTH token on the ElastiCache cluster
- Restrict security groups to port
6379from your application subnets only - Never place ElastiCache in a public subnet
Lambda Deployment
Lambda Layer (Extension)
└── extensions/
└── rate-limiter-extension (compiled from extension.ts)
Lambda Function
└── handler.js (compiled from handler.ts, wrapped with withRateLimit)The extension binary must be named to match what you register. Set AWS_LAMBDA_RUNTIME_API is injected automatically by the Lambda runtime.
Examples
Full runnable examples for every supported framework are in the examples/ directory:
| File | Framework | Notes |
|------|-----------|-------|
| examples/express.ts | Express | Built-in adapter |
| examples/fastify.ts | Fastify | Built-in adapter |
| examples/aws-lambda.ts | AWS Lambda | Extension + decorator |
| examples/koa.ts | Koa 2.x | Core API |
| examples/nestjs.ts | NestJS 10.x | Middleware + Guard patterns |
| examples/hapi.ts | Hapi 21.x | Core API |
| examples/node-http.ts | Node.js http | Core API, no framework |
| examples/rate-limiter.yaml | — | Full YAML config reference |
For deep dives into the internals, see the docs/ directory:
| Document | Topic | |----------|-------| | 01-architecture.md | System architecture and component map | | 02-algorithm.md | Sliding window counter math | | 03-redis-design.md | Key schema, TTL, cluster topology | | 06-scale-and-performance.md | 1M req/s strategy, reservoir, benchmarks | | 08-failure-modes.md | Failure taxonomy, circuit breaker, fallbacks | | 11-configuration.md | Full config schema reference |
