npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@wazobiatech/rate-limit

v1.0.0

Published

Production-grade rate limiting store interface for NestJS, Express, and Node.js

Downloads

87

Readme

@wazobiatech/rate-limit

Production-grade rate limiting for NestJS, Express, and plain Node.js. Five pluggable algorithms, two store backends (Redis and in-memory), a static facade with Wazobia-specific presets, and full TypeScript types.


Table of Contents

  1. Installation
  2. Algorithm Overview
  3. NestJS: Module Registration
  4. NestJS: @RateLimit() Decorator
  5. NestJS: Guard behaviour
  6. NestJS: Guard ordering
  7. Express: rateLimitMiddleware
  8. WazobiaRateLimit Facade
  9. Algorithm Classes — Direct Usage
  10. Store Backends
  11. Identifier Extraction and Key Building
  12. RateLimitConfig Reference
  13. Testing Patterns
  14. Public API Reference

Installation

npm install @wazobiatech/rate-limit

Peer dependencies — install what you use:

# NestJS integration
npm install @nestjs/common @nestjs/core reflect-metadata

# GraphQL support (optional — only if you rate-limit GraphQL mutations)
npm install @nestjs/graphql graphql

# Redis store (production)
npm install ioredis

# Express middleware (optional)
npm install express

Algorithm Overview

| Algorithm | Key property | Best for | |---|---|---| | sliding-window | Counter with TTL anchored to first request | Login IP throttle, general API caps | | fixed-window | Hard cap per discrete period; increments even on block | Daily SMS quota, toll fraud prevention | | leaky-bucket | Strict minimum interval, zero burst, fully atomic | SMS dispatch, OTP send, password reset emails | | failure-counter | Two-key lock + counter, exponential or flat backoff | Login lockout, OTP verify lockout | | token-bucket | Burst-tolerant lazy refill; allows burst then smooths | Authenticated API endpoints |


NestJS: Module Registration

Register the module once — typically in AppModule. Pass any IRateLimitStore implementation.

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import {
  RateLimitModule,
  RateLimitGuard,
  RedisStore,
} from '@wazobiatech/rate-limit';
import Redis from 'ioredis';

@Module({
  imports: [
    RateLimitModule.forRoot({
      store: new RedisStore(new Redis({ host: 'localhost', port: 6379 })),
    }),
  ],
  providers: [
    // Register as a global guard so every decorated endpoint is protected
    { provide: APP_GUARD, useClass: RateLimitGuard },
  ],
})
export class AppModule {}

For tests or single-process services with no Redis dependency:

import { InMemoryStore } from '@wazobiatech/rate-limit';

RateLimitModule.forRoot({ store: new InMemoryStore() })

NestJS: @RateLimit() Decorator

@RateLimit(config) attaches rate limit configuration to a controller class or a specific handler method. The guard reads this configuration at runtime using NestJS's Reflector.

If @RateLimit() is not present on either the handler or its class, the guard is a complete no-op for that route.

REST endpoints

import { Controller, Post, Get, Body } from '@nestjs/common';
import { RateLimit } from '@wazobiatech/rate-limit';

@Controller('auth')
export class AuthController {
  // Sliding window: 10 requests per 60 s per IP
  @Post('login')
  @RateLimit({
    algorithm: 'sliding-window',
    limit: 10,
    windowSeconds: 60,
    identifierCombo: 'ip',
    keyPrefix: 'auth:login:ip',
  })
  async login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }

  // Leaky bucket: strictly one request per 30 s per IP (no burst allowed)
  @Post('request-otp')
  @RateLimit({
    algorithm: 'leaky-bucket',
    intervalSeconds: 30,
    identifierCombo: 'ip',
    keyPrefix: 'auth:otp:ip',
  })
  async requestOtp(@Body() dto: OtpRequestDto) {
    return this.authService.sendOtp(dto.phone);
  }

  // Failure counter: guard checks the lock; auth service records failures
  @Post('verify-otp')
  @RateLimit({
    algorithm: 'failure-counter',
    maxFailures: 5,
    windowSeconds: 900,
    identifierCombo: 'email',
    keyPrefix: 'lock:otp',   // <-- must be the LOCK key prefix
  })
  async verifyOtp(@Body() dto: OtpVerifyDto) {
    return this.authService.verifyOtp(dto);
  }

  // Token bucket: 20 burst, then 5 req/s per authenticated user
  @Get('profile')
  @RateLimit({
    algorithm: 'token-bucket',
    limit: 20,
    refillRate: 5,
    identifierCombo: 'user',
    keyPrefix: 'api:profile',
  })
  async getProfile() {
    return this.userService.getProfile();
  }

  // Fixed window: 3 requests per 10 minutes per phone number
  @Post('resend-sms')
  @RateLimit({
    algorithm: 'fixed-window',
    limit: 3,
    windowSeconds: 600,
    identifierCombo: 'phone',
    keyPrefix: 'sms:resend',
  })
  async resendSms(@Body() dto: ResendDto) {
    return this.smsService.resend(dto.phone);
  }
}

GraphQL resolvers

The guard automatically detects GraphQL execution contexts and unwraps the underlying Express request from GqlExecutionContext. No extra configuration is needed.

Email and phone identifiers are extracted from req.body.variables (where Apollo Server places GraphQL mutation inputs).

import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { RateLimit } from '@wazobiatech/rate-limit';

@Resolver()
export class AuthResolver {
  // GraphQL mutation: 5 login attempts per 60 s per IP
  @Mutation(() => AuthPayload)
  @RateLimit({
    algorithm: 'fixed-window',
    limit: 5,
    windowSeconds: 60,
    identifierCombo: 'ip',
    keyPrefix: 'gql:login:ip',
  })
  async login(@Args('input') input: LoginInput) {
    return this.authService.login(input);
  }

  // Identifier extracted from mutation variables: { email: '...' }
  @Mutation(() => Boolean)
  @RateLimit({
    algorithm: 'leaky-bucket',
    intervalSeconds: 60,
    identifierCombo: 'email',  // reads from req.body.variables.email
    keyPrefix: 'gql:otp:email',
  })
  async requestOtp(@Args('email') email: string) {
    return this.authService.sendOtp(email);
  }
}

Note: The guard does not set the Retry-After HTTP response header for GraphQL contexts — GraphQL responses use the errors array, not HTTP headers. The retryAfter value is still present in the thrown HttpException body and available to a GraphQL exception filter.

Controller-level decoration

Apply @RateLimit() to the controller class to rate limit all its handlers with the same policy.

// Every handler in this controller is covered by the same 100 req/min IP limit
@RateLimit({
  algorithm: 'sliding-window',
  limit: 100,
  windowSeconds: 60,
  identifierCombo: 'ip',
  keyPrefix: 'api:global:ip',
})
@Controller('api')
export class ApiController {
  @Get('feed')
  getFeed() { ... }

  @Get('trending')
  getTrending() { ... }

  @Get('search')
  search() { ... }
}

Method-level override

A handler-level @RateLimit() always overrides the class-level one. The two policies use independent store keys so their counters never interfere.

@RateLimit({
  algorithm: 'sliding-window',
  limit: 100,
  windowSeconds: 60,
  identifierCombo: 'ip',
  keyPrefix: 'api:ip',
})
@Controller('api')
export class ApiController {
  @Get('feed')
  getFeed() { ... }  // ← uses class-level: 100 req/min per IP

  // Tighter per-user limit on a write operation
  @Post('publish')
  @RateLimit({
    algorithm: 'fixed-window',
    limit: 5,
    windowSeconds: 3600,
    identifierCombo: 'ip+user',
    keyPrefix: 'api:publish',   // ← different key namespace, different counter
  })
  publish() { ... }  // ← uses method-level: 5 req/hour per IP+user
}

NestJS: Guard Behaviour

When a rate-limited request is allowed, the guard attaches the following properties to the request object for downstream use (interceptors, handlers, logging):

| Property | Type | Description | |---|---|---| | req.rateLimitIds | { ip?, userId?, email?, phone? } | Identifiers extracted from the request | | req.rateLimitKey | string | Fully-qualified store key used for this check | | req.rateLimitResult | { allowed: boolean; retryAfter?: number; ... } | Raw algorithm decision |

When a rate-limited request is blocked:

  • The guard throws HttpException with status 429 Too Many Requests
  • Response body: { statusCode: 429, message: 'Too many requests', retryAfter: <number> }
  • For HTTP contexts: Retry-After: <seconds> header is set on the response
  • For GraphQL contexts: no HTTP header (handled by the GQL error transport)

NestJS: Guard Ordering

When using APP_GUARD, the order in which providers are registered determines execution order.

Login endpoint (user is not yet authenticated):

providers: [
  { provide: APP_GUARD, useClass: RateLimitGuard },  // 1. Rate limit first
  { provide: APP_GUARD, useClass: JwtAuthGuard },    // 2. Auth second
]

Rate limiting runs before authentication so that attackers can be blocked without going through credential verification.

Protected endpoints using ip+user combo:

The ip+user identifier combo requires req.user to be populated before the rate limit guard runs. Use handler-level @UseGuards() to control order precisely:

@UseGuards(JwtAuthGuard, RateLimitGuard)  // Auth first → populates req.user → RateLimit reads it
@Get('protected')
getProtected() { ... }

If req.user is absent when the rate limit guard runs with identifierCombo: 'ip+user', the guard throws a descriptive error: buildKey: identifier 'user' is required for combo 'ip+user' but was not extracted.


Express: rateLimitMiddleware

Use rateLimitMiddleware to apply rate limiting in a plain Express app or alongside NestJS in a mixed setup. The factory validates configuration at registration time — misconfigured routes fail at startup, not at first request.

import express from 'express';
import {
  rateLimitMiddleware,
  InMemoryStore,
  RedisStore,
} from '@wazobiatech/rate-limit';
import Redis from 'ioredis';

const app = express();
const store = new RedisStore(new Redis());

// Apply to a specific route
app.post(
  '/auth/login',
  rateLimitMiddleware(store, {
    algorithm: 'sliding-window',
    limit: 10,
    windowSeconds: 60,
    identifierCombo: 'ip',
    keyPrefix: 'login:ip',
  }),
  loginHandler,
);

Stacking multiple middlewares

Multiple rateLimitMiddleware instances stack naturally on a single route. Each enforces independently using its own store key namespace.

app.post(
  '/sms/send',
  // Per-phone pacing: max 3 per 10 minutes
  rateLimitMiddleware(store, {
    algorithm: 'fixed-window',
    limit: 3,
    windowSeconds: 600,
    identifierCombo: 'phone',
    keyPrefix: 'sms:phone:min',
  }),
  // Per-IP leaky bucket: 1 per 30 s (blocks IP cycling)
  rateLimitMiddleware(store, {
    algorithm: 'leaky-bucket',
    intervalSeconds: 30,
    identifierCombo: 'ip',
    keyPrefix: 'sms:ip',
  }),
  smsController.send,
);

onError option

Control behaviour when the backing store throws (e.g., Redis is down):

// 'allow' (default): fail open — request passes through, error forwarded to next(err)
rateLimitMiddleware(store, config, { onError: 'allow' })

// 'block': fail closed — return 429 even on store errors
// Use on security-critical paths where a degraded store is worse than downtime
rateLimitMiddleware(store, config, { onError: 'block' })

With onError: 'allow' (default), the error is passed to next(err), allowing your Express error handler to log it and return 500, while the request continues.

What middleware attaches to req

On an allowed request, the middleware sets:

req.rateLimitIds    // ExtractedIdentifiers
req.rateLimitKey    // string (store key)
req.rateLimitResult // { allowed: boolean; retryAfter?: number }

On a blocked request (429):

res.set('Retry-After', String(retryAfter));
res.status(429).json({ message: 'Too many requests', retryAfter });

WazobiaRateLimit Facade

WazobiaRateLimit is a static class that provides named factory methods for every Wazobia-specific rate limiting preset. Import it instead of instantiating algorithm classes manually — the correct algorithm, key prefix, and limits are already encoded.

import { WazobiaRateLimit, RedisStore } from '@wazobiatech/rate-limit';
import Redis from 'ioredis';

const store = new RedisStore(new Redis());

loginIpWindow

SlidingWindow — 20 requests per 10 minutes per IP

Attached to the login endpoint to slow password-spraying attacks from a single IP while allowing legitimate shared-IP users (offices, universities) through.

const ipLimit = WazobiaRateLimit.loginIpWindow(store);

// In your guard or service:
const result = await ipLimit.check(clientIp);
if (!result.allowed) {
  throw new TooManyRequestsException(`Retry after ${result.retryAfter}s`);
}

Store key: login:ip:{ip}

loginAccountLock

FailureCounter — 5 bad passwords trigger exponential lockout within a 1-hour window

The guard checks isLocked(email). Your auth service calls recordFailure(email) on a wrong password and reset(email) on success.

const accountLock = WazobiaRateLimit.loginAccountLock(store);

async function validateLogin(email: string, password: string) {
  // Check before any credential work
  if (await accountLock.isLocked(email)) {
    throw new TooManyRequestsException();
  }

  const valid = await checkPassword(email, password);

  if (!valid) {
    const { locked, failures } = await accountLock.recordFailure(email);
    if (locked) {
      throw new TooManyRequestsException(`Account locked after ${failures} failures`);
    }
    throw new UnauthorizedException('Invalid credentials');
  }

  // Success — reset the counter
  await accountLock.reset(email);
  return issueTokens(email);
}

Backoff schedule:

| Cumulative failures | Lock duration | |---|---| | 5–9 | 30 seconds | | 10–14 | 60 seconds | | 15–19 | 2 minutes | | 20–24 | 4 minutes | | 25–29 | 8 minutes | | 40+ | 1 hour (cap) |

Store keys: fail:login:{email}, lock:login:{email}

Critical: Never call recordFailure inside a guard. At guard time you have not yet verified the credential — you cannot know if the attempt would succeed. Recording a failure on every guard invocation would lock out legitimate users. Only call recordFailure after a confirmed bad credential in the auth layer.

smsPhoneMinuteCap

FixedWindow — 3 SMS per 10 minutes per phone number

Prevents a burst of messages when a user taps "Resend" multiple times in quick succession.

const minuteCap = WazobiaRateLimit.smsPhoneMinuteCap(store);

const result = await minuteCap.check(phoneNumber);
if (!result.allowed) {
  throw new TooManyRequestsException('SMS limit reached. Try again in 10 minutes.');
}
// Proceed to send SMS

Store key: sms:phone:min:{phone}

smsPhoneDayCap

FixedWindow — 10 SMS per 24 hours per phone number

Daily hard ceiling to prevent toll fraud regardless of how many flows (login, OTP, notification) are triggered.

const dayCap = WazobiaRateLimit.smsPhoneDayCap(store);

const result = await dayCap.check(phoneNumber);
if (!result.allowed) {
  throw new TooManyRequestsException('Daily SMS limit reached.');
}

Store key: sms:phone:day:{phone}

smsIpLeaky

LeakyBucket — 1 SMS per 30 seconds per IP address

Blocks an attacker from cycling through many phone numbers from the same IP to bypass the per-phone caps.

const ipLeaky = WazobiaRateLimit.smsIpLeaky(store);

const result = await ipLeaky.check(clientIp);
if (!result.allowed) {
  throw new TooManyRequestsException(`Please wait ${result.retryAfter}s before sending another SMS.`);
}

Store key: sms:ip:{ip}

otpVerifyCounter

FailureCounter — 5 wrong OTPs trigger a flat 15-minute lockout

Unlike the login lockout (exponential), OTP lockout is flat because the OTP itself expires in 15 minutes — there is no benefit to a longer lockout.

const otpGuard = WazobiaRateLimit.otpVerifyCounter(store);

async function verifyOtp(userId: string, submittedCode: string) {
  if (await otpGuard.isLocked(userId)) {
    throw new TooManyRequestsException('Too many incorrect attempts. Try again in 15 minutes.');
  }

  const valid = await validateOtpCode(userId, submittedCode);

  if (!valid) {
    await otpGuard.recordFailure(userId);
    throw new UnauthorizedException('Invalid OTP');
  }

  await otpGuard.reset(userId);
  return { success: true };
}

Lockout: flat 15 minutes regardless of how many failures.

Store keys: otp:attempts:{identifier}, otp:lock:{identifier}

apiTokenBucket

TokenBucket — 20 burst, 5 tokens/s sustained per user

Default configuration allows a burst of 20 requests immediately (covering parallel dashboard API calls), then a sustained rate of 5 req/s. Both capacity and refillRate are configurable.

// Default: capacity 20, refillRate 5
const apiLimit = WazobiaRateLimit.apiTokenBucket(store);

// Heavier endpoint: smaller burst, slower refill
const heavyEndpointLimit = WazobiaRateLimit.apiTokenBucket(store, {
  capacity: 5,
  refillRate: 1,
});

// Lightweight endpoint: larger burst allowed
const lightEndpointLimit = WazobiaRateLimit.apiTokenBucket(store, {
  capacity: 50,
  refillRate: 10,
});

// Usage
const result = await apiLimit.check(userId);
if (!result.allowed) {
  throw new TooManyRequestsException();
}
// Optionally set standard rate limit headers
res.set('X-RateLimit-Remaining', String(result.remaining));

Store key: api:user:{userId}

Layered SMS defence pattern

Apply all three SMS limiters together on the SMS send endpoint. Each catches a different attack vector and the three together form overlapping coverage:

// Express
app.post('/sms/send',
  async (req, res, next) => {
    const { phone } = req.body;
    const ip = extractIp(req);
    const store = app.get('rateLimitStore');

    // Layer 1: Per-phone burst prevention
    const minuteResult = await WazobiaRateLimit.smsPhoneMinuteCap(store).check(phone);
    if (!minuteResult.allowed) {
      return res.status(429).json({ message: 'Too many SMS attempts. Try again in 10 minutes.' });
    }

    // Layer 2: Per-phone daily cap
    const dayResult = await WazobiaRateLimit.smsPhoneDayCap(store).check(phone);
    if (!dayResult.allowed) {
      return res.status(429).json({ message: 'Daily SMS limit reached.' });
    }

    // Layer 3: Per-IP pacing (blocks multi-phone attacks from one IP)
    const ipResult = await WazobiaRateLimit.smsIpLeaky(store).check(ip);
    if (!ipResult.allowed) {
      return res.status(429).json({ message: `Wait ${ipResult.retryAfter}s before sending.` });
    }

    next();
  },
  smsController.send,
);

Algorithm Classes — Direct Usage

When the preset configurations in WazobiaRateLimit don't fit your use case, instantiate the algorithm classes directly.

SlidingWindow

Counter-approximation rolling window. Window anchors to the first request; TTL is never extended.

import { SlidingWindow } from '@wazobiatech/rate-limit';

const limiter = new SlidingWindow(store, {
  limit: 50,
  windowSeconds: 300,   // 5 minutes
  keyPrefix: 'custom:sw',
});

const result = await limiter.check(identifier);
// result: { allowed, count, remaining, retryAfter? }
// Or with a pre-built key (e.g., combining multiple dimensions)
const key = `${keyPrefix}:${ip}:${countryCode}`;
const result = await limiter.checkKey(key);

Important: Despite the name, this is a fixed-window counter anchored to the first request. See ARCHITECTURE.md for the distinction and its security implications.

FixedWindow

Hard cap per discrete period. Counter increments on blocked calls (intentional — burns abuse quota faster).

import { FixedWindow } from '@wazobiatech/rate-limit';

const limiter = new FixedWindow(store, {
  limit: 10,
  windowSeconds: 86400,  // 24 hours
  keyPrefix: 'daily:cap',
});

const result = await limiter.check(phoneNumber);
// result: { allowed, count, remaining, retryAfter? }
// retryAfter is present (in seconds) when allowed=false

LeakyBucket

Atomic strict-interval enforcer. One allowed action per intervalSeconds; zero burst.

import { LeakyBucket } from '@wazobiatech/rate-limit';

const limiter = new LeakyBucket(store, {
  intervalSeconds: 60,
  keyPrefix: 'pw:reset:ip',
});

const result = await limiter.check(clientIp);
// result: { allowed, retryAfter? }
// retryAfter is accurate (equals intervalSeconds — no approximation)

FailureCounter

Two-key account lockout with configurable backoff.

import { FailureCounter } from '@wazobiatech/rate-limit';

// Exponential backoff (default — doubles every 5 failures)
const loginLock = new FailureCounter(store, {
  maxFailures: 5,
  windowSeconds: 3600,
  keyPrefix: 'fail:login',
  lockKeyPrefix: 'lock:login',
});

// Flat backoff
const otpLock = new FailureCounter(store, {
  maxFailures: 5,
  windowSeconds: 900,
  keyPrefix: 'fail:otp',
  lockKeyPrefix: 'lock:otp',
  backoff: () => 900,  // always 15 minutes
});

// Custom backoff function
const customLock = new FailureCounter(store, {
  maxFailures: 3,
  windowSeconds: 3600,
  keyPrefix: 'fail:custom',
  lockKeyPrefix: 'lock:custom',
  backoff: (failures) => failures * 30,  // 30s per failure: 3→90s, 4→120s, …
});

// API
await lock.isLocked(identifier);                  // boolean
await lock.recordFailure(identifier);             // { locked, failures }
await lock.reset(identifier);                     // void
await lock.check(identifier);                     // { allowed }
await lock.checkKey(fullyQualifiedLockKey);       // { allowed }

TokenBucket

Lazy-refill burst-tolerant throttle.

import { TokenBucket } from '@wazobiatech/rate-limit';

const limiter = new TokenBucket(store, {
  capacity: 10,      // maximum burst
  refillRate: 2,     // 2 tokens/second = 2 req/s sustained
  keyPrefix: 'api:heavy',
});

const result = await limiter.check(userId);
// result: { allowed, tokens (pre-deduction), remaining (post-deduction) }

if (result.allowed) {
  res.set('X-RateLimit-Remaining', String(result.remaining));
}

Concurrency note: The GET → compute → SET sequence is not atomic in Redis. Under concurrent load, an occasional extra request may slip through at the capacity boundary. This is an accepted trade-off for API throttling where one extra request is not a security risk.


Store Backends

RedisStore

Production store. Uses ioredis. Required for multi-instance deployments (multiple processes or containers must share rate limit state).

import { RedisStore } from '@wazobiatech/rate-limit';
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: Number(process.env.REDIS_PORT),
  password: process.env.REDIS_PASSWORD,
  tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
});

const store = new RedisStore(redis);

RedisStore accepts any ioredis Redis instance. It uses five Redis commands: INCR, EXPIRE, GET, SET, DEL. It works with Redis Cluster but all keys for a single rate limit check must be on the same slot (true by default since keys are derived from the same identifier).

InMemoryStore

In-process store. No external dependencies. Not suitable for multi-process or containerised deployments — each process has its own counters.

import { InMemoryStore } from '@wazobiatech/rate-limit';

const store = new InMemoryStore();

Use in tests: Pass to any algorithm class or RateLimitModule.forRoot() for fast, deterministic tests without a Redis connection.

Use in single-process services: Suitable for development environments and single-instance deployments where cross-process state sharing is not needed.

Memory management: Expired entries are evicted lazily (on read). For long-running services with high identifier churn, call cleanup() periodically to evict entries that are never re-read:

// Proactive cleanup every 60 seconds
setInterval(() => store.cleanup(), 60_000);

_setWithExpiry (tests only): Write a key with an absolute expiry timestamp rather than a relative TTL. Useful when using jest.useFakeTimers() and you need to pre-seed the store at a precise fake-clock position.

jest.useFakeTimers();
store._setWithExpiry('key', 'value', Date.now() + 30_000);
jest.advanceTimersByTime(29_999);
expect(await store.get('key')).toBe('value');  // still alive
jest.advanceTimersByTime(2);
expect(await store.get('key')).toBeNull();     // expired

Identifier Extraction and Key Building

The package provides two utilities for extracting rate limit identifiers from HTTP requests and constructing namespaced store keys.

extractIdentifiers(req)

Works with Express Request, NestJS HTTP context, and Apollo GraphQL context (structural duck-typing — no framework import needed).

import { extractIdentifiers } from '@wazobiatech/rate-limit';

const ids = extractIdentifiers(req);
// ids: { ip?: string, userId?: string, email?: string, phone?: string }

IP resolution order (first non-empty wins):

  1. x-forwarded-for header — leftmost entry in the proxy chain
    ⚠ Only trustworthy when trust proxy is configured: app.set('trust proxy', 1). Without it, clients can spoof this header to bypass IP-based limits.
  2. x-real-ip header — set by Nginx
  3. req.socket.remoteAddress — direct TCP peer address
  4. req.ip — Express/Fastify trust-proxy value

userId: req.user?.id → fallback to req.user?.sub (JWT sub claim)

email / phone: req.body.emailreq.body.variables.email (Apollo GraphQL) → req.query.email

buildKey(prefix, combo, ids)

Constructs a colon-delimited namespaced store key from a prefix, identifier combination, and extracted identifiers.

import { buildKey } from '@wazobiatech/rate-limit';

buildKey('login',  'ip',       ids)  // → 'login:1.2.3.4'
buildKey('login',  'ip+user',  ids)  // → 'login:1.2.3.4:user-123'
buildKey('sms',    'ip+phone', ids)  // → 'sms:1.2.3.4:+2348012345678'
buildKey('lock',   'email',    ids)  // → 'lock:[email protected]'

Throws if any identifier required by the combo is absent:

Error: buildKey: identifier 'user' is required for combo 'ip+user'
       but was not extracted from the request

This is intentional — silently falling back to a different key would change rate limit semantics without any warning.

Available identifier combos:

| Combo | Key format | Requires | |---|---|---| | ip | prefix:ip | IP header or socket address | | user | prefix:userId | req.user.id or req.user.sub | | email | prefix:email | req.body.email or req.body.variables.email | | phone | prefix:phone | req.body.phone or req.body.variables.phone | | ip+user | prefix:ip:userId | IP + authenticated user | | ip+email | prefix:ip:email | IP + email | | ip+phone | prefix:ip:phone | IP + phone number |


RateLimitConfig Reference

Used by @RateLimit(), rateLimitMiddleware(), and RateLimitGuard internally.

interface RateLimitConfig {
  /**
   * Which algorithm to apply.
   */
  algorithm: 'sliding-window' | 'fixed-window' | 'leaky-bucket' | 'failure-counter' | 'token-bucket';

  /**
   * Maximum requests allowed within the window.
   * Required for: sliding-window, fixed-window.
   * Also the burst capacity for: token-bucket.
   */
  limit?: number;

  /**
   * Window duration in seconds.
   * Required for: sliding-window, fixed-window.
   * Optional (default 3600) for: failure-counter.
   */
  windowSeconds?: number;

  /**
   * Minimum interval in seconds between allowed requests (leaky-bucket only).
   * Required for: leaky-bucket.
   */
  intervalSeconds?: number;

  /**
   * Maximum failures before lockout (failure-counter only).
   * Required for: failure-counter.
   */
  maxFailures?: number;

  /**
   * Backoff strategy for failure-counter.
   * 'exponential' (default): 30 s → 60 s → 120 s → ... → 3600 s cap
   * 'flat': constant lockout duration equal to windowSeconds (default: 300 s)
   */
  backoff?: 'exponential' | 'flat';

  /**
   * How identifiers are extracted and combined to form the rate limit key.
   */
  identifierCombo: 'ip' | 'user' | 'email' | 'phone' | 'ip+user' | 'ip+email' | 'ip+phone';

  /**
   * Namespace prefix for all store keys on this endpoint.
   * Must be unique per endpoint/algorithm combination to avoid key collisions.
   *
   * For failure-counter: this must be the LOCK key prefix (e.g. 'lock:login'),
   * because the guard only checks the lock flag — not the counter.
   */
  keyPrefix: string;

  /**
   * Tokens added to the bucket per second (token-bucket only).
   * Default: 5. Determines the sustained request rate after the initial burst.
   */
  refillRate?: number;
}

Common configuration mistakes:

// ❌ Wrong: using the counter prefix for failure-counter
@RateLimit({ algorithm: 'failure-counter', keyPrefix: 'fail:login', ... })

// ✅ Correct: use the lock prefix
@RateLimit({ algorithm: 'failure-counter', keyPrefix: 'lock:login', ... })
// The guard checks store.get('lock:login:[email protected]') — must match lockKeyPrefix in FailureCounter

// ❌ Wrong: sliding-window without required fields
@RateLimit({ algorithm: 'sliding-window', identifierCombo: 'ip', keyPrefix: 'x' })
// throws: 'sliding-window requires limit and windowSeconds'

// ✅ Correct
@RateLimit({ algorithm: 'sliding-window', limit: 10, windowSeconds: 60, identifierCombo: 'ip', keyPrefix: 'x' })

Testing Patterns

Basic guard test (mock reflector)

import 'reflect-metadata';
import { RateLimitGuard } from '@wazobiatech/rate-limit';
import { InMemoryStore } from '@wazobiatech/rate-limit';
import type { ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';

function makeGuard(config) {
  const store = new InMemoryStore();
  const reflector = { getAllAndOverride: jest.fn().mockReturnValue(config) } as unknown as Reflector;
  const guard = new RateLimitGuard(reflector, store);
  return { guard, store };
}

function httpCtx(req = { headers: { 'x-forwarded-for': '1.2.3.4' } }) {
  return {
    getType: () => 'http',
    getHandler: jest.fn().mockReturnValue({}),
    getClass: jest.fn().mockReturnValue({}),
    switchToHttp: () => ({ getRequest: () => req, getResponse: () => ({ header: jest.fn() }) }),
  } as unknown as ExecutionContext;
}

it('blocks after limit', async () => {
  jest.useFakeTimers();
  const { guard } = makeGuard({ algorithm: 'sliding-window', limit: 2, windowSeconds: 60, keyPrefix: 'test', identifierCombo: 'ip' });
  const req = { headers: { 'x-forwarded-for': '1.2.3.4' } };
  await guard.canActivate(httpCtx(req));
  await guard.canActivate(httpCtx(req));
  await expect(guard.canActivate(httpCtx(req))).rejects.toThrow();
  jest.useRealTimers();
});

Integration test with real DI container

import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { Controller } from '@nestjs/common';
import { RateLimitModule, RateLimitGuard, RateLimit, InMemoryStore } from '@wazobiatech/rate-limit';

@Controller()
class TestController {
  @RateLimit({ algorithm: 'fixed-window', limit: 2, windowSeconds: 60, keyPrefix: 'test', identifierCombo: 'ip' })
  limitedAction(): void {}
}

describe('guard integration', () => {
  let guard: RateLimitGuard;

  beforeEach(async () => {
    jest.useFakeTimers();
    const store = new InMemoryStore();
    const module = await Test.createTestingModule({
      imports: [RateLimitModule.forRoot({ store })],
      controllers: [TestController],
    }).compile();
    guard = module.get(RateLimitGuard);
  });

  afterEach(() => jest.useRealTimers());

  it('reads real @RateLimit() metadata via the DI Reflector', async () => {
    const req = { headers: { 'x-forwarded-for': '1.1.1.1' } };
    const ctx = {
      getType: () => 'http',
      getHandler: () => TestController.prototype.limitedAction,
      getClass: () => TestController,
      switchToHttp: () => ({ getRequest: () => req, getResponse: () => ({ header: jest.fn() }) }),
    };
    await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
    await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
    await expect(guard.canActivate(ctx as any)).rejects.toThrow();
  });
});

Testing WazobiaRateLimit presets

import { WazobiaRateLimit, InMemoryStore } from '@wazobiatech/rate-limit';

describe('loginAccountLock', () => {
  it('locks after 5 failures with 30 s backoff', async () => {
    jest.useFakeTimers();
    const store = new InMemoryStore();
    const lock = WazobiaRateLimit.loginAccountLock(store);

    for (let i = 0; i < 5; i++) await lock.recordFailure('[email protected]');

    expect(await lock.isLocked('[email protected]')).toBe(true);
    jest.advanceTimersByTime(29_999);
    expect(await lock.isLocked('[email protected]')).toBe(true);
    jest.advanceTimersByTime(2);
    expect(await lock.isLocked('[email protected]')).toBe(false);

    jest.useRealTimers();
  });
});

Testing Express middleware

import { rateLimitMiddleware, InMemoryStore } from '@wazobiatech/rate-limit';

function makeRes() {
  const res = { _status: 0, _headers: {}, _body: null };
  res.set = (name, value) => { res._headers[name] = value; return res; };
  res.status = (code) => { res._status = code; return res; };
  res.json = (body) => { res._body = body; return res; };
  return res;
}

it('returns 429 after limit', async () => {
  jest.useFakeTimers();
  const store = new InMemoryStore();
  const mw = rateLimitMiddleware(store, {
    algorithm: 'sliding-window', limit: 2, windowSeconds: 60,
    identifierCombo: 'ip', keyPrefix: 'test',
  });
  const req = { headers: { 'x-forwarded-for': '1.2.3.4' } };
  const res = makeRes();

  await mw(req, res, jest.fn());
  await mw(req, res, jest.fn());
  await mw(req, res, jest.fn());

  expect(res._status).toBe(429);
  jest.useRealTimers();
});

Public API Reference

Everything exported from the package root:

// Wazobia presets facade
export { WazobiaRateLimit } from '@wazobiatech/rate-limit';

// Store interface and implementations
export type { IRateLimitStore } from '@wazobiatech/rate-limit';
export { RedisStore, InMemoryStore } from '@wazobiatech/rate-limit';

// Algorithm classes
export { SlidingWindow, FixedWindow, LeakyBucket, FailureCounter, TokenBucket }
export type { SlidingWindowOptions, RateLimitResult }
export type { FixedWindowOptions, FixedWindowResult }
export type { LeakyBucketOptions, LeakyBucketResult }
export type { FailureCounterOptions, RecordFailureResult }
export type { TokenBucketOptions, TokenBucketResult }

// HTTP / GraphQL utilities
export { extractIdentifiers, buildKey } from '@wazobiatech/rate-limit';
export type { RequestLike, ExtractedIdentifiers, IdentifierCombo }

// Express middleware
export { rateLimitMiddleware } from '@wazobiatech/rate-limit';
export type { MiddlewareOptions } from '@wazobiatech/rate-limit';

// NestJS integration
export { RateLimitGuard, RateLimitModule, RateLimit } from '@wazobiatech/rate-limit';
export type { RateLimitModuleOptions, RateLimitConfig, AlgorithmType }