{"id":48372018,"url":"https://github.com/ashwinpaulallen/ratelimit-flex","last_synced_at":"2026-04-05T17:01:51.825Z","repository":{"id":347253961,"uuid":"1193341316","full_name":"ashwinpaulallen/ratelimit-flex","owner":"ashwinpaulallen","description":"Flexible, TypeScript-first rate limiting for Node.js — sliding window, token bucket, fixed window — with Express, Fastify, Redis, and presets","archived":false,"fork":false,"pushed_at":"2026-04-04T16:14:07.000Z","size":386,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-04T16:52:14.804Z","etag":null,"topics":["express","fastify","histogram","metrics","middleware","monitoring","nodejs","npm","observability","opentelemetry","rate-limiter","rate-limiting","redis","sliding-window","token-bucket","typescript"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/ratelimit-flex","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ashwinpaulallen.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-27T05:47:13.000Z","updated_at":"2026-04-04T16:09:40.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ashwinpaulallen/ratelimit-flex","commit_stats":null,"previous_names":["ashwinpaulallen/ratelimit-flex"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/ashwinpaulallen/ratelimit-flex","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashwinpaulallen%2Fratelimit-flex","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashwinpaulallen%2Fratelimit-flex/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashwinpaulallen%2Fratelimit-flex/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashwinpaulallen%2Fratelimit-flex/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ashwinpaulallen","download_url":"https://codeload.github.com/ashwinpaulallen/ratelimit-flex/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ashwinpaulallen%2Fratelimit-flex/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31442924,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-05T15:22:31.103Z","status":"ssl_error","status_checked_at":"2026-04-05T15:22:00.205Z","response_time":75,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["express","fastify","histogram","metrics","middleware","monitoring","nodejs","npm","observability","opentelemetry","rate-limiter","rate-limiting","redis","sliding-window","token-bucket","typescript"],"created_at":"2026-04-05T17:01:50.860Z","updated_at":"2026-04-05T17:01:51.817Z","avatar_url":"https://github.com/ashwinpaulallen.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ratelimit-flex\n\nFlexible, TypeScript-first rate limiting for Node.js with Express and Fastify.\n\n[![npm version](https://img.shields.io/npm/v/ratelimit-flex.svg)](https://www.npmjs.com/package/ratelimit-flex)\n[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)\n![Tests](https://img.shields.io/badge/tests-vitest%20passing-brightgreen)\n![TypeScript](https://img.shields.io/badge/TypeScript-First-3178C6?logo=typescript\u0026logoColor=white)\n![Node](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js\u0026logoColor=white)\n\n- **Three strategies:** sliding window, token bucket, fixed window\n- **Frameworks:** Express and Fastify (separate entry for Fastify to keep bundles lean)\n- **Stores:** `MemoryStore` (in-process), `RedisStore` (shared, Lua-backed), and `ClusterStore` (Node.js native cluster IPC)\n- **Request queuing:** Queue over-limit requests instead of rejecting them immediately (`expressQueuedRateLimiter`, `fastifyQueuedRateLimiter`, `createRateLimiterQueue`)\n- **TypeScript-first:** strict types, discriminated options where it matters\n- **Redis resilience:** insurance limiter fallback, circuit breaker, counter sync on recovery; or **`fail-open`** / **`fail-closed`** when Redis is unavailable without insurance ([Redis failure handling](#redis-failure-handling), [Redis resilience](#redis-resilience))\n- **Metrics \u0026 observability (Express \u0026 Fastify):** aggregated snapshots, Prometheus, OpenTelemetry — `metrics: true`\n- **Weighted requests:** `incrementCost` (or `store.increment(..., { cost })`) so expensive endpoints consume more quota than cheap ones\n- **Presets:** `singleInstancePreset`, `multiInstancePreset`, `resilientRedisPreset`, `clusterPreset`, `queuedClusterPreset`, `apiGatewayPreset`, `authEndpointPreset`, `publicApiPreset`\n- **Limiter composition:** `compose.all()`, `compose.overflow()`, `compose.firstAvailable()`, `compose.race()`, `compose.windows()`, `compose.withBurst()`, nested `ComposedStore` — see [Limiter composition](#limiter-composition)\n- **Programmatic key management:** `KeyManager` for blocks, penalties, rewards, events, audit log, and optional admin HTTP API — see [Programmatic key management](#programmatic-key-management)\n\n## Installation\n\n```bash\nnpm install ratelimit-flex\n```\n\n```bash\nyarn add ratelimit-flex\n```\n\n```bash\npnpm add ratelimit-flex\n```\n\n**Peer dependencies (install only what you use):**\n\n| Package | When you need it |\n|---------|------------------|\n| `express` (+ `@types/express` for TS) | Express middleware |\n| `fastify`, `fastify-plugin` | Fastify plugin (`ratelimit-flex/fastify`) |\n| `ioredis` | `RedisStore` with `url` (or use your own Redis client adapter) |\n| `prom-client` | Optional: `metrics.prometheus.registry` integration |\n| `@opentelemetry/api` | Optional: `metrics.openTelemetry.meter` integration |\n\nAll peers are optional at install time; the runtime you choose must be present when you import that integration.\n\n**Node.js:** `\u003e= 20` (see `package.json` `engines`).\n\n## Quick Start\n\n**Express (6 lines):**\n\n```ts\nimport express from 'express';\nimport rateLimit from 'ratelimit-flex';\n\nconst app = express();\napp.use(rateLimit({ maxRequests: 100, windowMs: 60_000 }));\napp.get('/health', (_req, res) =\u003e res.json({ ok: true }));\n```\n\n**Fastify (6 lines):**\n\n```ts\nimport Fastify from 'fastify';\nimport { fastifyRateLimiter } from 'ratelimit-flex/fastify';\n\nconst app = Fastify();\nawait app.register(fastifyRateLimiter, { maxRequests: 100, windowMs: 60_000 });\napp.get('/health', async () =\u003e ({ ok: true }));\n```\n\n## Programmatic key management\n\nratelimit-flex exposes a `KeyManager` for programmatic control of rate limit keys. Block abusive clients, apply penalty/reward points, inspect state, and react to events — all with full TypeScript types, an audit trail, and optional Redis persistence.\n\n### Basic usage\n\n```typescript\nimport express from 'express';\nimport { KeyManager, MemoryStore, RateLimitStrategy, expressRateLimiter } from 'ratelimit-flex';\n\nconst app = express();\nconst store = new MemoryStore({ strategy: RateLimitStrategy.SLIDING_WINDOW, windowMs: 60_000, maxRequests: 100 });\nconst keyManager = new KeyManager({ store, maxRequests: 100, windowMs: 60_000 });\n\nconst limiter = expressRateLimiter({ store, keyManager });\napp.use(limiter);\n\n// Programmatic control — from an admin route, webhook handler, etc.\nawait keyManager.block('abusive-ip', 3600_000, { type: 'manual', message: 'Spam detected' });\nawait keyManager.penalty('suspicious-user', 5);\nawait keyManager.reward('verified-user', 10);\nconst state = await keyManager.get('any-key');\n```\n\n### Escalating penalties\n\n```typescript\nimport { KeyManager, exponentialEscalation } from 'ratelimit-flex';\n\nconst keyManager = new KeyManager({\n  store,\n  maxRequests: 100,\n  windowMs: 60_000,\n  penaltyBlockThreshold: 3,\n  penaltyEscalation: exponentialEscalation(60_000), // 1min, 2min, 4min, 8min...\n});\n```\n\n### Event-driven alerting\n\n```typescript\nkeyManager.on('blocked', ({ key, reason }) =\u003e {\n  alerting.send(`Key ${key} blocked: ${reason.type}`);\n});\n```\n\n### Admin endpoints\n\n```typescript\nimport { createAdminRouter } from 'ratelimit-flex';\n\napp.use('/admin/ratelimit', authMiddleware, createAdminRouter(keyManager));\n// GET /admin/ratelimit/keys/:key\n// POST /admin/ratelimit/keys/:key/block\n// etc.\n```\n\n### What `KeyManager` provides\n\n`KeyManager` gives you typed **block reasons** (`manual`, `penalty-escalation`, `abuse-pattern`, `custom`), an **event emitter** (`blocked`, `unblocked`, `penalized`, `rewarded`, and more), an **audit log** with filtering, **escalation strategies** for automatic penalty blocks, optional **admin REST endpoints** (`createAdminRouter`, `fastifyAdminPlugin`), and optional **Redis-backed block persistence** (`RedisBlockStore`) so block state can be shared across processes.\n\n### Redis-backed block persistence\n\nShare block state across processes using `RedisBlockStore`:\n\n```typescript\nimport { KeyManager, RedisBlockStore, RedisStore, RateLimitStrategy } from 'ratelimit-flex';\nimport Redis from 'ioredis';\n\n// Create a single Redis client instance\nconst redis = new Redis(process.env.REDIS_URL!);\n\n// Share the client between RedisStore (for rate limit counters) and RedisBlockStore (for blocks)\nconst store = new RedisStore({\n  client: redis,\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 100,\n});\n\nconst blockStore = new RedisBlockStore(redis, { keyPrefix: 'rlf:blocks:' });\n\nconst keyManager = new KeyManager({\n  store,\n  blockStore,\n  maxRequests: 100,\n  windowMs: 60_000,\n  syncIntervalMs: 5000, // Pull remote blocks every 5 seconds\n});\n\n// Blocks are now persisted to Redis and visible across all processes\nawait keyManager.block('abusive-ip', 3600_000, { type: 'manual', message: 'Spam' });\n```\n\n**Cross-process consistency:** `KeyManager` syncs blocks from Redis every `syncIntervalMs` (default 5000ms). Call `await keyManager.syncBlocks()` manually for immediate consistency.\n\n### Migrating from `penaltyBox`\n\nThe `penaltyBox` option is now powered by `KeyManager` internally. For full control, migrate to `KeyManager`:\n\n**Before (penaltyBox):**\n\n```typescript\napp.use(expressRateLimiter({\n  store,\n  penaltyBox: {\n    violationsThreshold: 3,\n    penaltyDurationMs: 60_000,\n  },\n}));\n```\n\n**After (KeyManager):**\n\n```typescript\nconst keyManager = new KeyManager({\n  store,\n  maxRequests: 100,\n  windowMs: 60_000,\n  penaltyBlockThreshold: 3,\n  penaltyBlockDurationMs: 60_000,\n});\n\napp.use(expressRateLimiter({ store, keyManager }));\n\n// Now you have programmatic access:\nawait keyManager.block('abusive-ip', 3600_000, { type: 'manual' });\nkeyManager.on('blocked', ({ key, reason }) =\u003e console.log(`Blocked: ${key}`));\n```\n\n**Benefits of migrating:**\n- Typed block reasons (`manual`, `penalty-escalation`, `abuse-pattern`, `custom`)\n- Event system for real-time alerting\n- Audit log with filtering\n- Escalation strategies (exponential, fibonacci, etc.)\n- Admin HTTP endpoints\n- Redis-backed block persistence\n\n## Limiter composition\n\nCombine multiple rate limiters with the `compose` builder. Every composition mode implements `RateLimitStore`, so composed stores plug directly into `expressRateLimiter` / `fastifyRateLimiter` via the `store` option.\n\n### Composition modes\n\n| Mode | Behavior | Use case | API |\n|------|----------|----------|-----|\n| **`all`** | Block if **any** layer blocks; rollback succeeded layers when one blocks | Multi-window limiting (10/sec AND 100/min AND 1000/hour) | `compose.all(...)` |\n| **`overflow`** | Try primary first; if blocked, try burst pool (primary counts stay) | Steady rate + burst allowance (5/sec + 20 burst tokens) | `compose.overflow(primary, burst)` or `compose.withBurst({ ... })` |\n| **`first-available`** | Try layers in order; first that allows wins (failed attempts rolled back) | Failover chain (Redis → fallback memory) | `compose.firstAvailable(...)` |\n| **`race`** | Fire all layers in parallel; fastest response wins | Multi-region latency optimization | `compose.race(...)` |\n\n### Examples\n\n**Multi-window** (10/sec AND 100/min — both must allow):\n\n```typescript\nimport { compose, expressRateLimiter, MemoryStore, RateLimitStrategy } from 'ratelimit-flex';\n\nconst store = compose.all(\n  compose.layer('per-sec', new MemoryStore({ \n    strategy: RateLimitStrategy.SLIDING_WINDOW, \n    windowMs: 1_000, \n    maxRequests: 10 \n  })),\n  compose.layer('per-min', new MemoryStore({ \n    strategy: RateLimitStrategy.SLIDING_WINDOW, \n    windowMs: 60_000, \n    maxRequests: 100 \n  })),\n);\n\napp.use(expressRateLimiter({ store }));\n```\n\n**Shorthand** — `compose.windows()` auto-creates `MemoryStore` instances:\n\n```typescript\nimport { compose, expressRateLimiter } from 'ratelimit-flex';\n\nconst store = compose.windows(\n  { windowMs: 1_000, maxRequests: 10 },\n  { windowMs: 60_000, maxRequests: 100 },\n);\n\napp.use(expressRateLimiter({ store }));\n```\n\n**Burst allowance** (steady rate + burst pool):\n\n```typescript\nimport { compose, expressRateLimiter } from 'ratelimit-flex';\n\nconst store = compose.withBurst({\n  steady: { windowMs: 1_000, maxRequests: 5 },\n  burst:  { windowMs: 60_000, maxRequests: 20 },\n});\n\napp.use(expressRateLimiter({ store }));\n```\n\n**Failover chain** (try Redis, fall back to memory):\n\n```typescript\nimport { compose, expressRateLimiter, MemoryStore, RedisStore, RateLimitStrategy } from 'ratelimit-flex';\n\nconst primary = new RedisStore({ \n  url: process.env.REDIS_URL!, \n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 100,\n  onRedisError: 'fail-open',\n});\n\nconst fallback = new MemoryStore({ \n  strategy: RateLimitStrategy.SLIDING_WINDOW, \n  windowMs: 60_000, \n  maxRequests: 100 \n});\n\nconst store = compose.firstAvailable(\n  compose.layer('redis', primary),\n  compose.layer('memory', fallback),\n);\n\napp.use(expressRateLimiter({ store }));\n```\n\n**Nested composition** — `ComposedStore` can be a layer in another `ComposedStore`:\n\n```typescript\nimport { compose, expressRateLimiter } from 'ratelimit-flex';\n\n// Overflow (steady + burst) inside all (with hour cap)\nconst rate = compose.overflow(\n  compose.layer('steady', steadyStore),\n  compose.layer('burst', burstStore),\n);\n\nconst store = compose.all(\n  compose.layer('rate', rate),\n  compose.layer('hourly-cap', hourlyCapStore),\n);\n\napp.use(expressRateLimiter({ store }));\n```\n\n### Per-layer observability\n\n```typescript\nimport { compose, expressRateLimiter } from 'ratelimit-flex';\n\nconst store = compose.all(\n  compose.layer('per-sec', perSecStore),\n  compose.layer('per-min', perMinStore),\n);\n\napp.use(expressRateLimiter({\n  store,\n  onLayerBlock: (req, label, layerResult) =\u003e {\n    console.log(`Layer '${label}' blocked:`, layerResult);\n  },\n}));\n\n// Access per-layer results\napp.use((req, res, next) =\u003e {\n  if (req.rateLimitComposed?.layers) {\n    console.log('Per-second:', req.rateLimitComposed.layers['per-sec']);\n    console.log('Per-minute:', req.rateLimitComposed.layers['per-min']);\n  }\n  next();\n});\n\n// Human-readable summary\nconsole.log(store.summarize('client-key'));\n// \"ALLOWED by 'per-sec' | per-sec: 9/10 remaining | per-min: 99/100 remaining\"\n```\n\n### Redis composition presets\n\n**Multi-window with Redis** (10/sec + 100/min + 1000/hour):\n\n```typescript\nimport { expressRateLimiter, multiWindowPreset } from 'ratelimit-flex';\n\napp.use(expressRateLimiter(\n  multiWindowPreset(\n    { url: process.env.REDIS_URL! },\n    [\n      { windowMs: 1_000, maxRequests: 10 },\n      { windowMs: 60_000, maxRequests: 100 },\n      { windowMs: 3_600_000, maxRequests: 1000 },\n    ],\n  ),\n));\n```\n\n**Burst with Redis**:\n\n```typescript\nimport { expressRateLimiter, burstablePreset } from 'ratelimit-flex';\n\napp.use(expressRateLimiter(\n  burstablePreset(\n    { url: process.env.REDIS_URL! },\n    {\n      steady: { windowMs: 1_000, maxRequests: 5 },\n      burst: { windowMs: 60_000, maxRequests: 20 },\n    },\n  ),\n));\n```\n\n**Failover preset**:\n\n```typescript\nimport { expressRateLimiter, failoverPreset } from 'ratelimit-flex';\n\napp.use(expressRateLimiter(\n  failoverPreset([\n    { label: 'primary', store: primaryRedisStore },\n    { label: 'fallback', store: fallbackMemoryStore },\n  ]),\n));\n```\n\n### Composition highlights\n\n| Capability | In ratelimit-flex |\n|------------|-------------------|\n| Multi-window limits (every window must allow) | `compose.all()` — implements `RateLimitStore` for Express/Fastify middleware |\n| Steady rate + burst pool | `compose.overflow()` or `compose.withBurst()` |\n| Nested compositions | Any `ComposedStore` can be a layer inside another |\n| Per-layer visibility | `onLayerBlock`, `req.rateLimitComposed`, `summarize()`, `extractLayerMetrics()` |\n\n### Migration from `limits` array\n\nThe `limits` array is now powered by the composition system internally. **Existing code works unchanged:**\n\n```typescript\n// Still works (backward compatible)\napp.use(expressRateLimiter({\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  limits: [\n    { windowMs: 1_000, max: 10 },\n    { windowMs: 60_000, max: 100 },\n  ],\n}));\n\n// Equivalent with compose (more control)\napp.use(expressRateLimiter({\n  store: compose.windows(\n    { windowMs: 1_000, maxRequests: 10 },\n    { windowMs: 60_000, maxRequests: 100 },\n  ),\n}));\n```\n\n## Request queuing\n\n**Typical use case:** Outbound API throttling (one queue per external API, single key for all requests).\n\n**Head-of-line blocking:** The queue is a single FIFO array. If you use multiple different keys with the same queue, a blocked request for key \"A\" will cause requests for key \"B\" to wait, even if \"B\" has capacity. For independent keys, create one queue per key instead (see examples below).\n\n```typescript\n// Outbound API rate limiting (non-HTTP)\nimport { createRateLimiterQueue } from 'ratelimit-flex';\n\nconst githubQueue = createRateLimiterQueue({\n  maxRequests: 30,\n  windowMs: 60_000,\n  maxQueueSize: 200,\n});\n\n// In your code — waits instead of rejecting\nawait githubQueue.removeTokens('github-api');\nconst response = await fetch('https://api.github.com/repos/...');\n```\n\n```typescript\n// HTTP middleware — queue instead of 429 (Express)\nimport { expressQueuedRateLimiter } from 'ratelimit-flex';\n\napp.use('/slow-endpoint', expressQueuedRateLimiter({\n  maxRequests: 5,\n  windowMs: 10_000,\n  maxQueueSize: 50,\n  maxQueueTimeMs: 30_000,\n}));\n// Requests over 5/10s are held and released when quota opens up\n```\n\n```typescript\n// HTTP middleware — queue instead of 429 (Fastify)\nimport { fastifyQueuedRateLimiter } from 'ratelimit-flex/fastify';\n\nawait app.register(fastifyQueuedRateLimiter, {\n  maxRequests: 5,\n  windowMs: 10_000,\n  maxQueueSize: 50,\n  maxQueueTimeMs: 30_000,\n});\n// Requests over 5/10s are held and released when quota opens up\n// Fastify plugin automatically calls queue.shutdown() on server close\n```\n\n**Multiple independent keys:** Create one queue per key to avoid head-of-line blocking:\n\n```typescript\n// ❌ Bad: single queue with multiple keys causes head-of-line blocking\nconst sharedQueue = createRateLimiterQueue({ maxRequests: 10, windowMs: 1000 });\nawait sharedQueue.removeTokens('user:alice'); // Blocks...\nawait sharedQueue.removeTokens('user:bob');   // ...waits even if bob has capacity\n\n// ✅ Good: separate queue per key\nconst queues = new Map\u003cstring, RateLimiterQueue\u003e();\nfunction getQueue(userId: string) {\n  if (!queues.has(userId)) {\n    queues.set(userId, createRateLimiterQueue({ maxRequests: 10, windowMs: 1000 }));\n  }\n  return queues.get(userId)!;\n}\nawait getQueue('alice').removeTokens('user:alice'); // Independent\nawait getQueue('bob').removeTokens('user:bob');     // Independent\n```\n\n**Graceful shutdown:**\n\n```typescript\n// Express: manually call shutdown on SIGTERM\nconst limiter = expressQueuedRateLimiter({ maxRequests: 10, windowMs: 60_000 });\napp.use(limiter);\n\nprocess.on('SIGTERM', async () =\u003e {\n  limiter.queue.shutdown(); // Rejects all pending requests and closes the store\n  await server.close();\n});\n```\n\n```typescript\n// Fastify: automatic shutdown via onClose hook\nawait app.register(fastifyQueuedRateLimiter, {\n  maxRequests: 10,\n  windowMs: 60_000,\n});\n// Plugin automatically calls queue.shutdown() when server closes\n```\n\n**Store ownership:** The queue takes ownership of the backing store. Calling `queue.shutdown()` will close the store via `store.shutdown()`. If you share a store across multiple queues or components, use `queue.clear()` instead of `queue.shutdown()` to avoid closing the shared store prematurely.\n\n## Choosing a strategy\n\n| Strategy       | Best for                     | Accuracy | Memory | Burst handling   |\n|----------------|------------------------------|----------|--------|------------------|\n| Sliding window | General API rate limiting    | High     | Medium | Smooth           |\n| Token bucket   | APIs that allow bursts       | High     | Low    | Allows bursts    |\n| Fixed window   | Simple counting, low memory  | Moderate | Low    | Edge spikes      |\n\n**Sliding window** — Counts requests in a moving time window. Best default when you care about fairness and boundary behavior (no big “reset line” artifacts).\n\n```ts\nimport { expressRateLimiter, RateLimitStrategy } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter({\n    strategy: RateLimitStrategy.SLIDING_WINDOW,\n    windowMs: 60_000,\n    maxRequests: 100,\n  }),\n);\n```\n\n**Token bucket** — Refills tokens on a schedule; clients can burst up to `bucketSize`. Good for spiky traffic (mobile, retries, webhooks).\n\n```ts\nimport { expressRateLimiter, RateLimitStrategy } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter({\n    strategy: RateLimitStrategy.TOKEN_BUCKET,\n    tokensPerInterval: 20,\n    interval: 60_000,\n    bucketSize: 60,\n  }),\n);\n```\n\n**Fixed window** — One counter per fixed time slice. Simplest and lightest; acceptable when occasional boundary spikes are OK (internal tools, coarse limits).\n\n```ts\nimport { expressRateLimiter, RateLimitStrategy } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter({\n    strategy: RateLimitStrategy.FIXED_WINDOW,\n    windowMs: 60_000,\n    maxRequests: 100,\n  }),\n);\n```\n\n## Weighted / cost-based rate limiting\n\nBy default each request consumes **one** quota unit. For endpoints that should count more (file uploads, heavy database work, high GraphQL complexity), use a **cost** greater than `1`.\n\n**Middleware / engine** — set **`incrementCost`** on the rate limiter options (number or function of the request):\n\n```ts\nimport { expressRateLimiter } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter({\n    maxRequests: 100,\n    windowMs: 60_000,\n    incrementCost: (req) =\u003e\n      String((req as import('express').Request).path ?? '').startsWith('/upload') ? 10 : 1,\n  }),\n);\n```\n\n**Custom pipelines** — call the store directly with **`increment`** / **`decrement`** options:\n\n```ts\nawait store.increment(key, { cost: 10 });\n// … later, undo the same weight (e.g. custom skip logic):\nawait store.decrement(key, { cost: 10 });\n```\n\nDynamic caps plus cost still work together: **`increment`** accepts **`{ maxRequests?, cost? }`** on window strategies.\n\nHelpers **`resolveIncrementOpts(options, req)`** and **`matchingDecrementOptions(incOpts)`** are exported if you build your own middleware and need the same increment/decrement pairing as the built-in engine.\n\n**Redis implementation note:** for sliding windows with **`cost \u003e 1`**, each ZSET member is a distinct random value so Redis never silently merges two hits into one.\n\n## Deployment guide\n\n### When to use MemoryStore\n\nUse **MemoryStore** when:\n\n- One Node process serves all traffic (no horizontal scale)\n- Local development and prototyping\n- Automated tests\n- Small deployments with a single instance\n\nCounters live **only in that process**. No Redis required.\n\n```ts\nimport { expressRateLimiter, MemoryStore, RateLimitStrategy } from 'ratelimit-flex';\n\nconst store = new MemoryStore({\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 100,\n});\n\napp.use(expressRateLimiter({ store, windowMs: 60_000, maxRequests: 100 }));\n```\n\nIf you omit `store`, the middleware creates a `MemoryStore` from `windowMs` / `maxRequests` (or token-bucket fields).\n\n### When to use ClusterStore\n\nUse **ClusterStore** when:\n\n- Node.js native **`cluster`** module (not PM2)\n- No Redis available or desired\n- Single server with multiple CPU cores\n\n```ts\n// primary.ts (ESM — top-level await)\nimport cluster from 'node:cluster';\nimport { ClusterStorePrimary } from 'ratelimit-flex';\n\nif (cluster.isPrimary) {\n  ClusterStorePrimary.init();\n  for (let i = 0; i \u003c 4; i++) cluster.fork();\n} else {\n  await import('./app.js');\n}\n```\n\n```ts\n// app.ts (worker)\nimport express from 'express';\nimport { expressRateLimiter, clusterPreset } from 'ratelimit-flex';\n\nconst app = express();\napp.use(expressRateLimiter(clusterPreset({ maxRequests: 100, windowMs: 60_000 })));\n```\n\n### When to use RedisStore\n\nUse **RedisStore** when:\n\n- Multiple Node processes (e.g. PM2 cluster)\n- Multiple servers behind a load balancer\n- Kubernetes, Docker Swarm, or similar\n- Microservices where the same client can hit **different** instances\n- You need one global limit across replicas\n\n```ts\nimport { expressRateLimiter, RedisStore, RateLimitStrategy } from 'ratelimit-flex';\n\nconst store = new RedisStore({\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 100,\n  url: process.env.REDIS_URL!,\n});\n\napp.use(expressRateLimiter({ store, strategy: RateLimitStrategy.SLIDING_WINDOW }));\n```\n\nPrefer passing a **shared Redis URL or client** from every instance. Use a **distinct key prefix** (`keyPrefix`) per app or per limiter if several services share one Redis.\n\n**Multi-window:** The convenience **`limits: [{ windowMs, max }, …]`** option (see [Multi-window limits (`limits`)](#multi-window-limits-limits)) creates one **`MemoryStore` per window**. It does **not** switch those slots to Redis automatically. For the same multi-window policy across horizontally scaled processes, build **`groupedWindowStores`** with one **`RedisStore`** (or other shared `RateLimitStore`) per slot.\n\n### Deployment topology\n\n| Setup | Store | What’s shared | What’s per-process |\n|-------|--------|----------------|---------------------|\n| Single process | `MemoryStore` | Everything (one process) | N/A |\n| Node.js native `cluster` (same host, forked workers) | `ClusterStore` + `ClusterStorePrimary` | Rate limit counters (on primary) | Allowlist, blocklist, penalty |\n| PM2 cluster (same host) | `RedisStore` | Rate limit counters | Allowlist, blocklist, penalty |\n| Multiple servers + LB | `RedisStore` | Rate limit counters | Allowlist, blocklist, penalty |\n| Kubernetes pods | `RedisStore` | Rate limit counters | Allowlist, blocklist, penalty |\n| Microservices (one global limit) | `RedisStore` (same namespace/prefix) | Rate limit counters | Allowlist, blocklist, penalty |\n| Microservices (per-service limits) | `RedisStore` (different prefix/DB) | Per-service counters | Allowlist, blocklist, penalty |\n\n**PM2 vs Node `cluster`:** **`ClusterStore`** (Node’s native `cluster` IPC with **`ClusterStorePrimary`** on the primary) is **not** for PM2 cluster mode. PM2 runs independent worker processes and uses its own IPC to the daemon, not a Node `cluster` primary/worker tree. For PM2, use **`RedisStore`** (or another shared store). At startup, **`ClusterStore`** detects PM2 (`PM2_HOME` or `pm_id`) and throws a clear error if the process is not a Node cluster worker.\n\n**Sticky sessions:** If your load balancer uses sticky sessions, `MemoryStore` can appear to work, but it is fragile—deploys and restarts reset counters per instance. **`RedisStore` survives restarts** and stays consistent across nodes.\n\n### Auto-detection and warnings\n\n**`detectEnvironment()`** returns flags such as `isKubernetes`, `isDocker`, `isCluster`, `isMultiInstance`, and a **`recommended`** store (`'memory'` | `'redis'`). Use it in your own startup logging or configuration.\n\n```ts\nimport { detectEnvironment } from 'ratelimit-flex';\n\nconst env = detectEnvironment();\nif (env.recommended === 'redis' \u0026\u0026 !process.env.REDIS_URL) {\n  console.warn('Production-like environment detected; consider Redis for shared limits.');\n}\n```\n\nExpress and Fastify integrations also call **`warnIfMemoryStoreInCluster`** once at startup: if a **MemoryStore** is used and the process looks like a **multi-instance** environment (e.g. Docker, Kubernetes, PM2), a **one-time** stderr warning is printed.\n\nSuppress with:\n\n```bash\nRATELIMIT_FLEX_NO_MEMORY_WARN=1\n```\n\nSimilarly, if **`RedisStore`** is used **without** an insurance limiter (`resilience.insuranceLimiter`) in a multi-instance-looking environment, a **one-time** stderr reminder suggests **`resilientRedisPreset`** or configuring insurance for failover protection.\n\nSuppress with:\n\n```bash\nRATELIMIT_FLEX_NO_RESILIENCE_WARN=1\n```\n\n## Presets\n\nPresets return a **`Partial\u003cRateLimitOptions\u003e`** you can pass to `expressRateLimiter` / `fastifyRateLimiter` (or spread and override).\n\n### `singleInstancePreset(options?)`\n\n**When:** Dev, tests, single-process apps.\n\n- Sliding window, **100 req / min** (defaults), in-memory (no `store` in preset—middleware builds `MemoryStore`).\n\n```ts\nimport { expressRateLimiter, singleInstancePreset } from 'ratelimit-flex';\n\napp.use(expressRateLimiter(singleInstancePreset({ maxRequests: 200 })));\n```\n\n### `multiInstancePreset(redisOptions, options?)`\n\n**When:** Production with Redis, multiple workers or nodes.\n\n- `RedisStore`, sliding window, **100 req / min**\n- **`onRedisError`:** `fail-open` by default (override via `redisOptions.onRedisError`)\n\n```ts\nimport { expressRateLimiter, multiInstancePreset } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter(\n    multiInstancePreset({ url: process.env.REDIS_URL! }, { maxRequests: 500 }),\n  ),\n);\n```\n\n### `resilientRedisPreset(redisOptions, options?)`\n\n**When:** Production **Redis** with **insurance** (in-memory fallback), **circuit breaker**, optional **counter sync** on recovery, and per-worker limit scaling. See [Redis resilience](#redis-resilience) for behavior, examples, and comparison with fail-open / fail-closed.\n\n### `clusterPreset(options?)`\n\n**When:** Node.js native `cluster` module (not PM2), single server with multiple CPU cores, no Redis.\n\n- `ClusterStore`, sliding window, **100 req / min**\n- Requires `ClusterStorePrimary.init()` on the primary process\n\n```ts\n// primary.ts\nimport cluster from 'node:cluster';\nimport { ClusterStorePrimary } from 'ratelimit-flex/cluster';\n\nif (cluster.isPrimary) {\n  ClusterStorePrimary.init();\n  for (let i = 0; i \u003c 4; i++) cluster.fork();\n} else {\n  await import('./app.js');\n}\n```\n\n```ts\n// app.ts (worker)\nimport { expressRateLimiter, clusterPreset } from 'ratelimit-flex';\n\napp.use(expressRateLimiter(clusterPreset({ maxRequests: 100, windowMs: 60_000 })));\n```\n\n### `queuedClusterPreset(options?)`\n\n**When:** Node.js native `cluster` + **request queuing** (queue over-limit requests instead of rejecting them).\n\n- `ClusterStore` + `expressQueuedRateLimiter` / `fastifyQueuedRateLimiter`\n- Sliding window, **100 req / min**, **queue size 100**, **30s max wait**\n- Requires `ClusterStorePrimary.init()` on the primary process\n\n```ts\n// primary.ts\nimport cluster from 'node:cluster';\nimport { ClusterStorePrimary } from 'ratelimit-flex/cluster';\n\nif (cluster.isPrimary) {\n  ClusterStorePrimary.init();\n  for (let i = 0; i \u003c 4; i++) cluster.fork();\n} else {\n  await import('./app.js');\n}\n```\n\n```ts\n// app.ts (worker)\nimport { expressQueuedRateLimiter, queuedClusterPreset } from 'ratelimit-flex';\n\napp.use('/api', expressQueuedRateLimiter(queuedClusterPreset({\n  maxRequests: 50,\n  windowMs: 60_000,\n  maxQueueSize: 200,\n})));\n```\n\n### `apiGatewayPreset(redisOptions, options?)`\n\n**When:** API gateway–style traffic, key per client credential.\n\n- Token bucket (~**30** tokens/min, **burst 60**), **`x-api-key`** key generator\n- **`fail-closed`** when Redis is down (override possible)\n\n```ts\nimport { expressRateLimiter, apiGatewayPreset } from 'ratelimit-flex';\n\napp.use('/v1', expressRateLimiter(apiGatewayPreset({ url: process.env.REDIS_URL! })));\n```\n\n### `authEndpointPreset(redisOptions, options?)`\n\n**When:** Login, signup, password reset—brute-force protection.\n\n- **Fixed window**, **5 req / min** per IP (default), IP-based key\n- **`fail-closed`** when Redis is down\n\n```ts\nimport { expressRateLimiter, authEndpointPreset } from 'ratelimit-flex';\n\napp.post(\n  '/login',\n  expressRateLimiter(authEndpointPreset({ url: process.env.REDIS_URL! }, { maxRequests: 10 })),\n  loginHandler,\n);\n```\n\n### `publicApiPreset(options?)`\n\n**When:** Public HTTP APIs with a simple in-memory limit and structured JSON errors.\n\n- Sliding window, **60 req / min**, default `message` object\n\n```ts\nimport { expressRateLimiter, publicApiPreset } from 'ratelimit-flex';\n\napp.use('/public', expressRateLimiter(publicApiPreset()));\n```\n\n## Redis failure handling\n\n| Mode | Behavior if Redis errors during quota check |\n|------|-----------------------------------------------|\n| **`fail-open`** (default for `RedisStore`) | Request is **allowed**; warning logged |\n| **`fail-closed`** | Request is treated as **blocked**; middleware responds **503** with `{ error: 'Service temporarily unavailable' }` |\n\n**Recommendation:** **`fail-open`** for most general APIs (availability over strict quota). **`fail-closed`** for auth, payments, or when you must not serve traffic without a working limiter.\n\n```ts\n// Fail-open (default)\nnew RedisStore({ url: REDIS_URL, strategy: RateLimitStrategy.SLIDING_WINDOW, windowMs: 60_000, maxRequests: 100 });\n\n// Fail-closed\nnew RedisStore({\n  url: REDIS_URL,\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 100,\n  onRedisError: 'fail-closed',\n});\n```\n\n**Policy vs counters:** **Allowlist**, **blocklist**, and **penalty box** are enforced in the **RateLimitEngine** (in-memory) **before** the store runs. They **still apply** when Redis is down. Only **quota / window / bucket** counting depends on `RedisStore.increment`.\n\n## Redis resilience\n\nWhen Redis is unavailable, the default **`fail-open`** / **`fail-closed`** modes either allow every request or block every request globally—there is no per-client quota during the outage. An **insurance limiter** fixes that: a dedicated **`MemoryStore`** that activates automatically when the circuit breaker decides Redis is unhealthy, so each process still enforces **per-process** limits. Configure that in-memory cap as roughly **total shared limit ÷ expected worker count** (e.g. 300 requests/minute across 5 replicas → **60** per process) so failover traffic stays in the same ballpark as your global Redis budget.\n\n### Manual setup (`RedisStore` + `resilience`)\n\n```typescript\nimport { expressRateLimiter, RedisStore, MemoryStore, RateLimitStrategy } from 'ratelimit-flex';\n\nconst insuranceStore = new MemoryStore({\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 60, // 300 / 5 workers\n});\n\nconst store = new RedisStore({\n  strategy: RateLimitStrategy.SLIDING_WINDOW,\n  windowMs: 60_000,\n  maxRequests: 300,\n  url: process.env.REDIS_URL!,\n  resilience: {\n    insuranceLimiter: { store: insuranceStore },\n    circuitBreaker: { failureThreshold: 3, recoveryTimeMs: 5000 },\n    hooks: {\n      onFailover: (err) =\u003e console.error('Redis down, using fallback', err),\n      onRecovery: (ms) =\u003e console.log(`Redis recovered after ${ms}ms`),\n    },\n  },\n});\n\napp.use(expressRateLimiter({ store, strategy: RateLimitStrategy.SLIDING_WINDOW }));\n```\n\n### Preset (`resilientRedisPreset`)\n\n`resilientRedisPreset` wires the same idea—**Redis** + **insurance `MemoryStore`** + **circuit breaker**—and estimates worker count from the environment (or `estimatedWorkers`) so you do not hand-divide limits yourself:\n\n```typescript\nimport { expressRateLimiter, resilientRedisPreset } from 'ratelimit-flex';\n\napp.use(expressRateLimiter(\n  resilientRedisPreset(\n    { url: process.env.REDIS_URL! },\n    { maxRequests: 300, estimatedWorkers: 5 }\n  )\n));\n```\n\n### Circuit breaker\n\nThe breaker around Redis has three states:\n\n- **Closed** — Redis is used; successes reset failure streaks.\n- **Open** — Too many consecutive failures; requests are **not** sent to Redis (they go to the insurance store instead), avoiding wasted round-trips to a dead server.\n- **Half-open** — After a recovery window, a probe allows one Redis attempt; success **closes** the circuit, failure **reopens** it.\n\n### Counter sync\n\nWhen the circuit **closes** again after an outage, accumulated hits in the insurance **`MemoryStore`** can be **replayed into Redis** (`INCRBY`-style paths per strategy) so shared state catches up. This is **`syncOnRecovery: true`** by default on `resilience.insuranceLimiter` and can be set to **`false`** if you do not want that merge step.\n\n**Sliding window note:** replay bulk-inserts synthetic hits with timestamps at recovery time (counts match; the visible window is not time-smoothed across the outage — see JSDoc on `RedisStore` sync). **Fixed window** and **token bucket** sync paths behave as described in code comments.\n\n### Comparison: fail-open / fail-closed vs insurance limiter\n\n| Feature | fail-open / fail-closed | Insurance limiter |\n|---------|------------------------|-------------------|\n| Redis down behavior | Allow all or block all | Fallback to in-memory rate limiting |\n| Rate limiting during outage | None (open) or total block (closed) | Per-process limits enforced |\n| Circuit breaker | No | Yes — avoids wasted Redis round-trips |\n| Counter sync on recovery | No | Yes — replays in-memory hits to Redis |\n| Observability hooks | onRedisError only | onFailover, onRecovery, onCircuitOpen, onCircuitClose, onInsuranceHit, onCounterSync |\n\nWhen insurance is configured, it **replaces** the binary fail-open/fail-closed behavior for quota operations (see [Redis failure handling](#redis-failure-handling)).\n\n**HTTP:** middleware sets **`X-RateLimit-Store: fallback`** when `storeUnavailable` is true (insurance path) so monitors can tell primary Redis from fallback.\n\n## Metrics \u0026 Observability\n\nYou get production-grade observability for free — just flip a switch (`metrics: true`) on **Express** (`expressRateLimiter`) or **Fastify** (`fastifyRateLimiter` from `ratelimit-flex/fastify`). The same `RateLimitOptions.metrics` / `MetricsConfig` applies to both; only the **surface API** differs (handler methods vs. Fastify decorations — see below).\n\n### Why metrics matter for rate limiting\n\nRate limiters are invisible infrastructure: when they work, nobody notices; when they misconfigure or drift, they either let attacks through or frustrate legitimate users. Metrics make the invisible visible — throughput, block rates, latency, and hot keys — so you can tune limits, catch abuse, and prove SLAs.\n\n### Quick start\n\n**Express** — the middleware is also a metrics handle (`getMetricsSnapshot`, `on('metrics', …)`, etc.):\n\n```ts\nconst limiter = expressRateLimiter({ maxRequests: 100, metrics: true });\napp.get('/stats', (req, res) =\u003e res.json(limiter.getMetricsSnapshot()));\n```\n\n**Fastify** — same `RateLimitOptions.metrics`; the plugin decorates the instance when metrics are enabled (`rateLimitMetrics`, `getMetricsSnapshot`, `getMetricsHistory`, `on('metrics', …)` on `rateLimitMetrics`):\n\n```ts\nawait app.register(fastifyRateLimiter, { maxRequests: 100, metrics: true });\napp.get('/stats', async (request, reply) =\u003e {\n  const snap = app.getMetricsSnapshot?.() ?? null;\n  return reply.send(snap ?? { message: 'No snapshot yet' });\n});\n```\n\n**Framework API (same metrics, different wiring):**\n\n| Surface | Express (`expressRateLimiter`) | Fastify (`fastifyRateLimiter`) |\n|--------|-------------------------------|--------------------------------|\n| Metrics manager | `limiter.metricsManager` | `app.rateLimitMetrics` |\n| Latest / history | `limiter.getMetricsSnapshot()`, `getMetricsHistory()` | `app.getMetricsSnapshot?.()`, `getMetricsHistory?.()` |\n| `metrics` events | `limiter.on('metrics', …)` | `app.rateLimitMetrics?.on('metrics', …)` |\n| Prometheus `GET` | `limiter.metricsEndpoint` → `app.use('/metrics', …)` | `app.fastifyMetricsRoute` → `app.get('/metrics', …)` (native; `metricsEndpoint` still available for `@fastify/express`) |\n| Clean shutdown | `limiter.shutdownMetrics()` | Plugin **`onClose`** calls `metricsManager.shutdown()`; optional `await app.rateLimitMetrics?.shutdown()` |\n\n### What’s collected\n\nAggregated snapshots (and Prometheus / OpenTelemetry exporters when enabled) expose the following concepts. **Prometheus** metric names use the default prefix `ratelimit_` (configurable). **OpenTelemetry** uses `{prefix}_…` with default prefix `ratelimit` (e.g. `ratelimit_requests_total`). Prometheus also emits **`ratelimit_requests_skipped_total`** and **`ratelimit_requests_allowlisted_total`** as separate counters.\n\n| Metric (concept / series) | Type | Description |\n|---------------------------|------|-------------|\n| `requests_total` | Counter | Total requests by **status** and **reason** (allowed, blocked: rate_limit, blocklist, penalty, service_unavailable; skipped / allowlisted where applicable) |\n| `middleware_duration_ms` / `middleware_duration_milliseconds` | Histogram | Time spent in the rate limiter middleware per request (ms) |\n| `store_duration_ms` / `store_duration_milliseconds` | Histogram | Store `increment` latency (e.g. Redis) per operation (ms) |\n| `requests_per_second` | Gauge | Estimated throughput over the aggregation window |\n| `block_rate` | Gauge | Share of requests blocked (0–1) over the window |\n| `hot_key_hits` | Gauge | Top keys by hit count (cardinality capped; label `key`) |\n\n### Performance guarantee\n\nMetrics collection adds **less than ~2 microseconds per request** on typical hardware. Recording is **synchronous** — numeric increments and fixed ring buffers only: **no allocations** and **no I/O** on the request path. Aggregation runs on a **background timer** (default: every **10 seconds**).\n\n### Callback / Event-based metrics\n\n**Push — `onMetrics` callback** (fires each aggregation tick; same option for Express and Fastify):\n\nExpress:\n\n```ts\nexpressRateLimiter({\n  maxRequests: 100,\n  windowMs: 60_000,\n  metrics: {\n    enabled: true,\n    onMetrics: (snapshot) =\u003e {\n      if (snapshot.window.blockRate \u003e 0.1) console.warn('High block rate', snapshot);\n    },\n  },\n});\n```\n\nFastify:\n\n```ts\nimport { fastifyRateLimiter } from 'ratelimit-flex/fastify';\n\nawait app.register(fastifyRateLimiter, {\n  maxRequests: 100,\n  windowMs: 60_000,\n  metrics: {\n    enabled: true,\n    onMetrics: (snapshot) =\u003e {\n      if (snapshot.window.blockRate \u003e 0.1) console.warn('High block rate', snapshot);\n    },\n  },\n});\n```\n\n**Events — `on('metrics', …)`** (same snapshots as `onMetrics`):\n\nExpress — on the middleware handler:\n\n```ts\nconst limiter = expressRateLimiter({ maxRequests: 100, metrics: true });\nlimiter.on('metrics', (snapshot) =\u003e {\n  /* same shape as onMetrics */\n});\n```\n\nFastify — on `rateLimitMetrics` (a `MetricsManager`; only present when metrics are enabled):\n\n```ts\nawait app.register(fastifyRateLimiter, { maxRequests: 100, metrics: true });\napp.rateLimitMetrics?.on('metrics', (snapshot) =\u003e {\n  /* same shape as onMetrics */\n});\n```\n\n**Pull — latest snapshot** (`null` before the first aggregation tick):\n\nExpress:\n\n```ts\nconst snap = limiter.getMetricsSnapshot();\nres.json(snap ?? { message: 'No snapshot yet' });\n```\n\nFastify — the plugin decorates **`getMetricsSnapshot`** and **`getMetricsHistory`** on the instance:\n\n```ts\nconst snap = app.getMetricsSnapshot?.() ?? null;\nreturn reply.send(snap ?? { message: 'No snapshot yet' });\n```\n\n### Prometheus integration\n\n**Standalone (Express)** — text exposition **without** installing `prom-client`; use the middleware from the limiter:\n\n```ts\nconst limiter = expressRateLimiter({\n  maxRequests: 100,\n  metrics: { enabled: true, prometheus: { enabled: true } },\n});\nif (limiter.metricsEndpoint) {\n  app.use('/metrics', limiter.metricsEndpoint);\n}\n```\n\n**Standalone (Fastify)** — use the **native** route handler (no Express adapter):\n\n```ts\nawait app.register(fastifyRateLimiter, {\n  maxRequests: 100,\n  metrics: { enabled: true, prometheus: { enabled: true } },\n});\nif (app.fastifyMetricsRoute) {\n  app.get('/metrics', app.fastifyMetricsRoute);\n}\n```\n\n(`metricsEndpoint` is still set for apps that mount Express middleware via `@fastify/express` / `middie`; prefer `fastifyMetricsRoute` for plain Fastify.)\n\n**With an existing `prom-client` registry** — pass your `Registry`; scrape your global `/metrics` as usual.\n\nExpress:\n\n```ts\nimport { Registry } from 'prom-client';\n\nconst registry = new Registry();\nexpressRateLimiter({\n  maxRequests: 100,\n  metrics: { enabled: true, prometheus: { enabled: true, registry } },\n});\n```\n\nFastify (same `metrics` object; register the plugin, then mount `/metrics` with `fastifyMetricsRoute` as above):\n\n```ts\nimport { Registry } from 'prom-client';\nimport { fastifyRateLimiter } from 'ratelimit-flex/fastify';\n\nconst registry = new Registry();\nawait app.register(fastifyRateLimiter, {\n  maxRequests: 100,\n  metrics: { enabled: true, prometheus: { enabled: true, registry } },\n});\nif (app.fastifyMetricsRoute) {\n  app.get('/metrics', app.fastifyMetricsRoute);\n}\n```\n\n**Example PromQL / Grafana queries:**\n\n```promql\nsum(rate(ratelimit_requests_total{status=\"blocked\"}[5m]))\n```\n\n```promql\nhistogram_quantile(\n  0.99,\n  sum(rate(ratelimit_middleware_duration_milliseconds_bucket[5m])) by (le)\n)\n```\n\n### OpenTelemetry integration\n\nPass a **`Meter`** from `@opentelemetry/api` (optional peer dependency). Works with any **OTLP-compatible** backend — **Grafana Cloud**, **Datadog**, **New Relic**, **Honeycomb**, self-hosted collectors, etc.\n\nExpress:\n\n```ts\nimport { metrics } from '@opentelemetry/api';\nimport { expressRateLimiter } from 'ratelimit-flex';\n\nconst meter = metrics.getMeter('my-service');\napp.use(\n  expressRateLimiter({\n    maxRequests: 100,\n    metrics: { enabled: true, openTelemetry: { enabled: true, meter, prefix: 'ratelimit' } },\n  }),\n);\n```\n\nFastify:\n\n```ts\nimport { metrics } from '@opentelemetry/api';\nimport { fastifyRateLimiter } from 'ratelimit-flex/fastify';\n\nconst meter = metrics.getMeter('my-service');\nawait app.register(fastifyRateLimiter, {\n  maxRequests: 100,\n  metrics: { enabled: true, openTelemetry: { enabled: true, meter, prefix: 'ratelimit' } },\n});\n```\n\nOn shutdown, call **`limiter.openTelemetryAdapter?.shutdown()`** (Express) or **`app.rateLimitMetrics?.getOpenTelemetryAdapter()?.shutdown()`** (Fastify) if you need to tear down observable gauge callbacks cleanly. The Fastify plugin also runs **`metricsManager.shutdown()`** on `onClose`.\n\n### Snapshot API\n\n**`MetricsSnapshot`** (from the collector; `getMetricsSnapshot()` returns the latest):\n\n```ts\ninterface MetricsSnapshot {\n  readonly timestamp: Date;\n  readonly window: {\n    readonly durationMs: number;\n    readonly requestsPerSecond: number;\n    readonly blocksPerSecond: number;\n    readonly blockRate: number;\n    readonly allowRate: number;\n  };\n  readonly totals: {\n    readonly requests: number;\n    readonly allowed: number;\n    readonly blocked: number;\n    readonly skipped: number;\n    readonly allowlisted: number;\n  };\n  readonly blockReasons: {\n    readonly rateLimit: number;\n    readonly blocklist: number;\n    readonly penalty: number;\n    readonly serviceUnavailable: number;\n  };\n  readonly latency: {\n    readonly min: number;\n    readonly max: number;\n    readonly mean: number;\n    readonly p50: number;\n    readonly p95: number;\n    readonly p99: number;\n    readonly stdDev: number;\n  };\n  readonly storeLatency: {\n    readonly min: number;\n    readonly max: number;\n    readonly mean: number;\n    readonly p50: number;\n    readonly p95: number;\n    readonly p99: number;\n  };\n  readonly hotKeys: ReadonlyArray\u003c{ readonly key: string; readonly hits: number; readonly blocked: number }\u003e;\n  readonly trends: {\n    readonly requestRateTrend: 'increasing' | 'decreasing' | 'stable';\n    readonly blockRateTrend: 'increasing' | 'decreasing' | 'stable';\n    readonly latencyTrend: 'increasing' | 'decreasing' | 'stable';\n  };\n  readonly latencySamplesMs?: readonly number[];\n  readonly storeLatencySamplesMs?: readonly number[];\n}\n```\n\n**Alerting — block rate above a threshold:**\n\nExpress:\n\n```ts\nlimiter.on('metrics', (s) =\u003e {\n  if (s.window.blockRate \u003e 0.25) {\n    void alerting.notify('Block rate above 25%', { blockRate: s.window.blockRate });\n  }\n});\n```\n\nFastify:\n\n```ts\napp.rateLimitMetrics?.on('metrics', (s) =\u003e {\n  if (s.window.blockRate \u003e 0.25) {\n    void alerting.notify('Block rate above 25%', { blockRate: s.window.blockRate });\n  }\n});\n```\n\n**Logging hot keys (abuse / capacity planning):**\n\nExpress:\n\n```ts\nlimiter.on('metrics', (s) =\u003e {\n  for (const row of s.hotKeys.slice(0, 5)) {\n    logger.info({ key: row.key, hits: row.hits, blocked: row.blocked }, 'top rate-limit key');\n  }\n});\n```\n\nFastify:\n\n```ts\napp.rateLimitMetrics?.on('metrics', (s) =\u003e {\n  for (const row of s.hotKeys.slice(0, 5)) {\n    logger.info({ key: row.key, hits: row.hits, blocked: row.blocked }, 'top rate-limit key');\n  }\n});\n```\n\n### Trends\n\nThe collector compares **recent vs earlier** samples in a sliding window (request rate, block rate, mean latency) and labels each series **`increasing`**, **`decreasing`**, or **`stable`**. Use **`snapshot.trends.*`** for proactive alerts (e.g. rising block rate before user complaints, or rising latency before timeouts).\n\n### MetricsConfig reference\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `enabled` | `boolean` | — | **Required** when using object form; master switch |\n| `intervalMs` | `number` | `10000` | Aggregation / emit interval (ms) |\n| `topKSize` | `number` | `20` | How many hot keys to keep in snapshots |\n| `histogramBuckets` | `number[]` | (library defaults) | Upper bounds (ms) for latency histograms |\n| `onMetrics` | `(snapshot: MetricsSnapshot) =\u003e void` | — | Called each tick with the latest snapshot |\n| `prometheus` | `{ enabled: boolean; prefix?: string; registry?: unknown }` | — | Prometheus text + optional `prom-client` registry |\n| `openTelemetry` | `{ enabled: boolean; meter?: unknown; prefix?: string }` | — | OTel instruments via user-supplied `Meter` |\n\nUse **`metrics: true`** as shorthand for `{ enabled: true }` with the defaults above. **Express:** call **`shutdownMetrics()`** on the middleware handler when the process exits (alongside store shutdown). **Fastify:** the plugin registers **`onClose`** to stop the collector and adapters when the server closes; call **`await app.rateLimitMetrics?.shutdown()`** only if you need an explicit teardown without closing Fastify.\n\n---\n\n## Configuration reference\n\nOptions are merged with strategy defaults. Omit **`store`** to get an auto-created **`MemoryStore`** (unless you use **`limits`**, which builds grouped in-memory stores).\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `strategy` | `RateLimitStrategy` | `SLIDING_WINDOW` | `SLIDING_WINDOW`, `FIXED_WINDOW`, `TOKEN_BUCKET` |\n| `store` | `RateLimitStore` | auto `MemoryStore` | Backing store |\n| `windowMs` | `number` | `60000` | Window length (sliding / fixed) |\n| `maxRequests` | `number` \\| `(req) =\u003e number` | `100` | Max requests per window (sliding / fixed) |\n| `incrementCost` | `number` \\| `(req) =\u003e number` | — | Quota units per request (`1` if omitted); use with weighted `store.increment` semantics |\n| `limits` | `{ windowMs, max }[]` | — | Multiple windows; block if **any** exceeded ([details](#multi-window-limits-limits)) |\n| `tokensPerInterval` | `number` | `10` | Token bucket refill rate |\n| `interval` | `number` | `60000` | Refill interval (token bucket) |\n| `bucketSize` | `number` | `100` | Max tokens / burst (token bucket) |\n| `keyGenerator` | `(req) =\u003e string` | IP / socket fallback | Storage key ([Client IP \u0026 reverse proxies](#client-ip-and-reverse-proxies)) |\n| `headers` | `boolean` | `true` | Legacy `X-RateLimit-*` when **`standardHeaders`** is omitted; see [Standard headers](#standard-headers) |\n| `standardHeaders` | `boolean` \\| `'legacy'` \\| `'draft-6'` \\| `'draft-7'` \\| `'draft-8'` | (see defaults) | Which response header profile to send ([Standard headers](#standard-headers)) |\n| `identifier` | `string` | `{limit}-per-{windowSeconds}` | Policy name for draft-8 / draft-7 policy strings |\n| `legacyHeaders` | `boolean` | (profile-dependent) | Also emit `X-RateLimit-*` alongside draft profiles |\n| `statusCode` | `number` | `429` | Status when rate-limited |\n| `message` | `string` \\| `object` | `\"Too many requests\"` | Response body (`{ error: message }`) |\n| `skip` | `(req) =\u003e boolean` | — | Skip limiting |\n| `skipFailedRequests` | `boolean` | `false` | Decrement on `\u003e= 400` responses |\n| `skipSuccessfulRequests` | `boolean` | `false` | Decrement on `\u003c 400` responses |\n| `onLimitReached` | `(req, result) =\u003e void` | — | After a block |\n| `metrics` | `MetricsConfig` \\| `boolean` | — | Aggregated metrics, Prometheus, OTel ([Metrics \u0026 Observability](#metrics--observability)) |\n| `allowlist` | `string[]` | — | Keys that skip limiting |\n| `blocklist` | `string[]` | — | Keys rejected before quota (`403` default) |\n| `blocklistStatusCode` | `number` | `403` | Status for blocklist |\n| `blocklistMessage` | `string` \\| `object` | `\"Forbidden\"` | Blocklist body |\n| `penaltyBox` | `PenaltyBoxOptions` | — | Ban after repeated violations |\n| `draft` | `boolean` | `false` | Observe would-be blocks without enforcing |\n| `onDraftViolation` | `(req, result) =\u003e void` | — | When `draft` and would block |\n\n**Penalty box**\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `violationsThreshold` | `number` | Blocks needed to trigger penalty |\n| `violationWindowMs` | `number` | `3600000` default | Sliding window for violation count |\n| `penaltyDurationMs` | `number` | — | How long the ban lasts |\n| `onPenalty` | `(req) =\u003e void` | Optional callback |\n\n**RedisStore**\n\n| Field | Type | Default | Description |\n|-------|------|---------|-------------|\n| `client` | `RedisLikeClient` | — | Existing client (xor `url`) |\n| `url` | `string` | — | Redis URL (needs `ioredis` for dynamic connect) |\n| `keyPrefix` | `string` | `\"rlf:\"` | Key prefix |\n| `onRedisError` | `'fail-open'` \\| `'fail-closed'` | `fail-open` | Behavior when Redis fails during increment |\n| `onWarn` | `(msg, err?) =\u003e void` | `console.warn` | Custom logging |\n\n## Standard headers\n\nExpress and Fastify attach rate-limit response headers via **`standardHeaders`**, **`headers`**, **`identifier`**, and **`legacyHeaders`**. The **`formatRateLimitHeaders()`** helper is also exported for custom middleware. See the IETF draft: **[RateLimit header fields for HTTP](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)**.\n\n### Quick comparison\n\n| Option | Headers sent | Format |\n|--------|-------------|--------|\n| `standardHeaders: 'legacy'` or `headers: true` (and `standardHeaders` omitted) | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` | Legacy (epoch timestamp) |\n| `standardHeaders: 'draft-6'` | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy` | IETF draft-6 (seconds) |\n| `standardHeaders: 'draft-7'` | `RateLimit` (combined), `RateLimit-Policy` | IETF draft-7 (structured fields) |\n| `standardHeaders: 'draft-8'` | `RateLimit` (named policy), `RateLimit-Policy` | IETF draft-8 (latest) |\n| `standardHeaders: false` | None | — |\n\nOn **429** (and other blocked responses where headers are enabled), **`Retry-After`** is included in seconds until reset — for legacy and draft profiles.\n\n**Note:** If the store’s **`resetTime`** is already in the past when headers are formatted (clock skew, slow handling), the seconds-until-reset value is **0**, so you may see **`Retry-After: 0`**. RFC 7231 defines that as “retry immediately” (valid); some clients treat **`0`** as no backoff and may retry aggressively — not a spec violation, but worth knowing for operators.\n\n**Grouped windows (`limits`):** policy metadata uses the **shortest** window length for **`w=`** and **`getLimit`**’s **minimum** cap across windows, so **`RateLimit-Policy`** / default **`identifier`** read like a single-window policy. That is a reasonable approximation but can mislead if you rely on headers to document a multi-window ruleset — set **`identifier`** (and document behavior out-of-band) when that matters. The shorthand **`limits`** array builds **in-memory** stores only; for shared counters across replicas, see [Multi-window limits (`limits`)](#multi-window-limits-limits).\n\n### Example\n\n```ts\nimport expressRateLimiter from 'ratelimit-flex';\n\n// Recommended for new APIs\napp.use(expressRateLimiter({\n  maxRequests: 100,\n  windowMs: 60_000,\n  standardHeaders: 'draft-8',\n  identifier: 'api-v1',\n}));\n\n// Response headers:\n// RateLimit-Policy: \"api-v1\";q=100;w=60\n// RateLimit: \"api-v1\";r=95;t=45\n// (Retry-After: 45  ← only on 429)\n```\n\n### Migration from express-rate-limit\n\nThe **`standardHeaders`** string values (`'draft-6'`, `'draft-7'`, `'draft-8'`) are intentionally aligned with **express-rate-limit**’s option names so you can migrate without renaming profiles. **`fromExpressRateLimitOptions()`** (exported from the main package) maps **`max` → `maxRequests`** and header flags. See [From `express-rate-limit`](#from-express-rate-limit).\n\n## Advanced features\n\n### Client IP and reverse proxies\n\nThe default storage key comes from **`defaultKeyGenerator`**: it prefers **`req.ip`**, then **`socket.remoteAddress`**, else **`\"unknown\"`**. Behind one or more reverse proxies or load balancers, the connection’s **`remoteAddress`** is often the **proxy**, not the end client. If **`req.ip`** is not derived from **`X-Forwarded-For`** (or your platform’s equivalent), every user can appear as the **same** key — too strict for real clients, or too loose for abusers sharing a proxy.\n\n**Express** — Set [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) so **`req.ip`** reflects the client (e.g. `app.set('trust proxy', 1)` or a hop count / subnet list that matches your deployment).\n\n**Fastify** — Set [`trustProxy`](https://fastify.dev/docs/latest/Reference/Server/#trustproxy) on the server so the request’s IP used by plugins matches the real client.\n\nAlternatively, stop relying on IP for identity: set **`keyGenerator`** to a stable per-user or per-tenant id (session, JWT subject, API key header), which is often clearer than parsing forwarded headers yourself.\n\n**Per-user / per-key limiting** — Set `keyGenerator` (API key, user id, tenant).\n\n```ts\napp.use(\n  expressRateLimiter({\n    maxRequests: 100,\n    windowMs: 60_000,\n    keyGenerator: (req) =\u003e\n      String((req as import('express').Request).header('x-api-key') ?? 'anonymous'),\n  }),\n);\n```\n\n**Global + per-route** — Register multiple middlewares with different options.\n\n```ts\napp.use(expressRateLimiter({ maxRequests: 100, windowMs: 60_000 }));\napp.use('/login', expressRateLimiter({ maxRequests: 5, windowMs: 60_000 }));\n```\n\n### Multi-window limits (`limits`)\n\nApply **several** sliding or fixed windows at once: a request is blocked if **any** window is exceeded. Pass **`limits`** as an array of **`{ windowMs, max }`** (merged to **`groupedWindowStores`** internally):\n\n```ts\nimport { expressRateLimiter, RateLimitStrategy } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter({\n    strategy: RateLimitStrategy.SLIDING_WINDOW,\n    limits: [\n      { windowMs: 60_000, max: 30 },\n      { windowMs: 3_600_000, max: 500 },\n    ],\n  }),\n);\n```\n\n**Horizontal scale:** That shorthand creates **one `MemoryStore` per window** in each Node process. Behind multiple app instances, each replica keeps **its own** counters, so effective limits are **per process**, not global. To enforce the same multi-window policy cluster-wide, omit **`limits`** and set **`groupedWindowStores`** explicitly: one entry per window, each with a **`store`** that points at a shared backend (typically **`RedisStore`** with the same **`windowMs`** / **`maxRequests`** as the slot). Single-instance or dev setups can keep using **`limits`** as-is.\n\n**Binding slot:** For headers and **`getLimit`**, the engine picks one **binding** window among grouped slots. If the request is **blocked**, that is the blocking window with the **latest** **`resetTime`** when several windows block at once. If the request is **allowed**, the binding slot is the one with the **lowest absolute** **`remaining`** count — **not** “most exhausted” as a **percentage** of each window’s cap. That matches typical setups (e.g. a tight per-minute cap next to a loose per-hour cap). Unusual mixes where a **higher** limit has **fewer** tokens left in absolute terms could label a different slot as “most constrained” than a **%-of-limit** rule would.\n\n**Dynamic limits** — `maxRequests` as a function (window strategies).\n\n```ts\napp.use(\n  expressRateLimiter({\n    windowMs: 60_000,\n    maxRequests: (req) =\u003e\n      (req as import('express').Request).user?.isPremium ? 1000 : 100,\n  }),\n);\n```\n\n**Weighted / cost-based limits** — see [Weighted / cost-based rate limiting](#weighted--cost-based-rate-limiting) (`incrementCost` or `store.increment(..., { cost })`).\n\n**Allowlist / blocklist**\n\n```ts\napp.use(\n  expressRateLimiter({\n    allowlist: ['203.0.113.10'],\n    blocklist: ['bad-key'],\n    keyGenerator: (req) =\u003e String((req as import('express').Request).header('x-api-key') ?? 'anon'),\n  }),\n);\n```\n\n**Penalty box**\n\n```ts\napp.use(\n  expressRateLimiter({\n    maxRequests: 10,\n    windowMs: 60_000,\n    penaltyBox: {\n      violationsThreshold: 5,\n      violationWindowMs: 3_600_000,\n      penaltyDurationMs: 900_000,\n    },\n  }),\n);\n```\n\n**Custom error responses** — `statusCode`, `message`, `blocklistMessage`, etc.\n\n```ts\napp.use(\n  expressRateLimiter({\n    maxRequests: 10,\n    windowMs: 60_000,\n    statusCode: 429,\n    message: { error: 'Slow down', code: 'RATE_LIMIT' },\n  }),\n);\n```\n\n**Skipping routes** — `skip(req)`.\n\n```ts\napp.use(\n  expressRateLimiter({\n    maxRequests: 100,\n    windowMs: 60_000,\n    skip: (req) =\u003e String((req as { path?: string }).path ?? '').startsWith('/health'),\n  }),\n);\n```\n\n## Custom stores\n\nImplement **`RateLimitStore`**:\n\n```ts\nexport interface RateLimitIncrementOptions {\n  maxRequests?: number;\n  /** Quota units consumed by this call (default 1). */\n  cost?: number;\n}\n\nexport interface RateLimitDecrementOptions {\n  /** Must match the `cost` of the increment being rolled back. */\n  cost?: number;\n}\n\nexport interface RateLimitStore {\n  increment(\n    key: string,\n    options?: RateLimitIncrementOptions,\n  ): Promise\u003c{\n    totalHits: number;\n    remaining: number;\n    resetTime: Date;\n    isBlocked: boolean;\n    storeUnavailable?: boolean;\n  }\u003e;\n  decrement(key: string, options?: RateLimitDecrementOptions): Promise\u003cvoid\u003e;\n  reset(key: string): Promise\u003cvoid\u003e;\n  shutdown(): Promise\u003cvoid\u003e;\n}\n```\n\nUse **`increment`’s optional `{ maxRequests }`** for dynamic caps on window strategies, and **`{ cost }`** for weighted requests. Implement **`decrement`** with the same **`cost`** when your integration rolls back a weighted increment. Back your store with PostgreSQL, DynamoDB, etc., if you need persistence without Redis—mind latency and atomicity for hot keys.\n\nPass your store as **`store`** in middleware options.\n\n## API reference\n\n| Export | Role |\n|--------|------|\n| **`expressRateLimiter(options)`** | Express middleware factory (`Partial\u003cRateLimitOptions\u003e`) |\n| **`fastifyRateLimiter`** | From `ratelimit-flex/fastify` — Fastify plugin |\n| **`createStore(options)`** | Build `MemoryStore` or `RedisStore` (`CreateStoreOptions`) |\n| **`detectEnvironment()`** | `EnvironmentInfo` — deployment hints |\n| **`singleInstancePreset`**, **`multiInstancePreset`**, **`resilientRedisPreset`**, **`apiGatewayPreset`**, **`authEndpointPreset`**, **`publicApiPreset`** | Opinionated `Partial\u003cRateLimitOptions\u003e` |\n| **`CircuitBreaker`**, **`RedisResilienceOptions`**, **`ResilienceHooks`**, **`InsuranceLimiterOptions`**, **`CircuitBreakerOptions`**, **`CircuitState`** | Circuit breaker and Redis failover types ([Redis resilience](#redis-resilience)) |\n| **`MemoryStore`** | In-memory store (`getActiveKeys` / `resetAll` for advanced sync scenarios) |\n| **`RedisStore`** | Redis-backed store (Lua); optional **`resilience`** for insurance + breaker |\n| **`RateLimitEngine`**, **`createRateLimitEngine`** | Core engine without HTTP |\n| **`resolveIncrementOpts`**, **`matchingDecrementOptions`** | Resolve per-request `increment` / `decrement` options (weighted limits) |\n| **`createRateLimiter`** | `{ express }` middleware helper |\n| **`MetricsManager`**, **`normalizeMetricsConfig`**, **`PrometheusAdapter`**, **`OpenTelemetryAdapter`** | Metrics wiring and exporters ([Metrics \u0026 Observability](#metrics--observability)) |\n\nDefault export = **`expressRateLimiter`**.\n\n## Migration guide\n\nOptions are the same **`RateLimitOptions`** shape for **`expressRateLimiter`** and **`fastifyRateLimiter`**; only the import path and how you mount the integration differ (see [Quick Start](#quick-start)).\n\n### From `express-rate-limit`\n\n| express-rate-limit | ratelimit-flex |\n|--------------------|----------------|\n| `max` | `maxRequests` |\n| `windowMs` | `windowMs` (unchanged) |\n| `standardHeaders: true` | `standardHeaders: 'draft-6'` (or use the helper below) |\n| `standardHeaders: false` | `standardHeaders: false` |\n| `standardHeaders: 'draft-6'` \\| `'draft-7'` \\| `'draft-8'` | Same string values |\n| `legacyHeaders` | `legacyHeaders` |\n| `headers: true` (older API) | Prefer `standardHeaders: 'legacy'` or explicit draft profile |\n\nUse **`fromExpressRateLimitOptions()`** (exported from **`ratelimit-flex`**) to map **`max` → `maxRequests`** and express-rate-limit **`standardHeaders`** / **`legacyHeaders`** semantics in one call:\n\n```ts\nimport expressRateLimiter, { fromExpressRateLimitOptions } from 'ratelimit-flex';\n\n// express-rate-limit:\n// rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true })\n\napp.use(\n  expressRateLimiter(\n    fromExpressRateLimitOptions({\n      windowMs: 15 * 60 * 1000,\n      max: 100,\n      standardHeaders: true,\n    }),\n  ),\n);\n```\n\nEquivalent manual mapping:\n\n```ts\nimport { expressRateLimiter } from 'ratelimit-flex';\n\napp.use(\n  expressRateLimiter({\n    windowMs: 15 * 60 * 1000,\n    maxRequests: 100,\n    standardHeaders: 'draft-6',\n    legacyHeaders: false,\n  }),\n);\n```\n\nDefault export is **`expressRateLimiter`** (same as named import). For **Redis** across instances, use **`RedisStore`**, **`multiInstancePreset`**, or **`resilientRedisPreset`** and wire **`url`** or **`client`** as in [Deployment guide](#deployment-guide).\n\n### From `@fastify/rate-limit`\n\n| `@fastify/rate-limit` | ratelimit-flex (`ratelimit-flex/fastify`) |\n|----------------------|-------------------------------------------|\n| `max` | `maxRequests` |\n| `timeWindow` (ms number) | `windowMs` (same numeric value) |\n| `timeWindow` (`'1 minute'` etc. via [`ms`](https://github.com/vercel/ms)) | `windowMs` — convert to milliseconds (e.g. `60_000` for one minute, or `import ms from 'ms'; ms('1 minute')`) |\n| `allowList` | `allowlist` |\n| `keyGenerator(request)` | `keyGenerator` — same idea; signature is **`(req: unknown) =\u003e string`** (pass your Fastify `request`) |\n| `redis` / `nameSpace` | Use **`RedisStore`** with **`url`** / **`client`** and **`keyPrefix`** (see [When to use RedisStore](#when-to-use-redisstore)) |\n| `skip` / `skipOnError` | `skip` — for Redis errors, configure **`onRedisError`** on **`RedisStore`** ([Redis failure handling](#redis-failure-handling)) |\n| `errorResponseBuilder` | `message` / `statusCode` |\n| `enableDraftSpec: true` | `standardHeaders: 'draft-6'` (or a newer draft profile) |\n| `ban` / `onBanReach` | No single drop-in — use **`penaltyBox`**, **`blocklist`**, or custom handlers as needed |\n| Per-route `fastify.rateLimit({ ... })` | Register scoped plugins or use different **`RateLimitOptions`** per route / plugin scope |\n\n```ts\n// @fastify/rate-limit\n// await fastify.register(import('@fastify/rate-limit'), { max: 100, timeWindow: '1 minute' });\n\n// ratelimit-flex\nimport { fastifyRateLimiter } from 'ratelimit-flex/fastify';\n\nawait fastify.register(fastifyRateLimiter, {\n  maxRequests: 100,\n  windowMs: 60_000,\n});\n```\n\n**`global: false`** in `@fastify/rate-limit` limits encapsulation to routes registered in that plugin’s scope. Achieve the same by registering **`fastifyRateLimiter`** in a [Fastify plugin encapsulation](https://fastify.dev/docs/latest/Reference/Plugins/) context (child instance) instead of the root app.\n\n## Contributing\n\n1. Clone the repo and run **`npm install`**\n2. **`npm test`** — Vitest\n3. **`npm run lint`** — ESLint\n4. **`npm run build`** — TypeScript (`dist/`)\n\nOpen a PR with a short description of behavior changes and any new tests.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fashwinpaulallen%2Fratelimit-flex","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fashwinpaulallen%2Fratelimit-flex","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fashwinpaulallen%2Fratelimit-flex/lists"}