UNPKG

nestjs-cluster-throttle

Version:

Enterprise-grade rate limiting module for NestJS with Redis support, multiple strategies, and cluster mode

538 lines (427 loc) 14.1 kB
# nestjs-cluster-throttle [![npm version](https://badge.fury.io/js/nestjs-cluster-throttle.svg)](https://badge.fury.io/js/nestjs-cluster-throttle) [![npm downloads](https://img.shields.io/npm/dm/nestjs-cluster-throttle.svg)](https://www.npmjs.com/package/nestjs-cluster-throttle) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Coverage Status](https://img.shields.io/badge/coverage-80%25-brightgreen)](https://claude.ai/chat/coverage) [![CI](https://github.com/rdobrynin/nestjs-cluster-throttle/workflows/CI/badge.svg)](https://github.com/your-username/nestjs-cluster-throttle/actions) Cluster-ready rate limiting module for NestJS with Redis support and multiple rate limiting strategies. ## Features - **Cluster-ready** - Works seamlessly in clustered environments - **Redis support** - Distributed rate limiting with Redis - **Memory storage** - In-memory storage for single-instance applications - **Flexible strategies** - Support for fixed-window, token-bucket, and sliding-window algorithms - **Decorator-based** - Easy-to-use decorators for route protection - **Geo-blocking** - IP-based country restrictions with multiple providers (internal, IP-API, custom) - **Highly configurable** - Customize limits, windows, and behavior - **Type-safe** - Written in TypeScript with full type support - **Fail-open strategy** - Gracefully handles storage failures ## Installation ```bash npm install nestjs-cluster-throttle ioredis # or yarn add nestjs-cluster-throttle ioredis ``` ## Requirements - Node.js >= 14.0.0 - NestJS >= 9.0.0 (supports v9, v10, v11) - TypeScript >= 5.0.0 ## Quick Start ### Basic Usage (In-Memory) ```typescript import { Module } from '@nestjs/common'; import { RateLimitModule } from 'nestjs-cluster-throttle'; @Module({ imports: [ RateLimitModule.forRoot({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs }), ], }) export class AppModule {} ``` ### Redis-based (Cluster Mode) ```typescript import { Module } from '@nestjs/common'; import { RateLimitModule } from 'nestjs-cluster-throttle'; @Module({ imports: [ RateLimitModule.forRoot({ windowMs: 15 * 60 * 1000, max: 100, clusterMode: true, redisOptions: { host: 'localhost', port: 6379, password: 'your-password', // optional db: 0, keyPrefix: 'rate-limit:', }, }), ], }) export class AppModule {} ``` ### Async Configuration ```typescript import { Module } from '@nestjs/common'; import { RateLimitModule } from 'nestjs-cluster-throttle'; import { ConfigModule, ConfigService } from '@nestjs/config'; @Module({ imports: [ RateLimitModule.forRootAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ windowMs: configService.get('RATE_LIMIT_WINDOW_MS'), max: configService.get('RATE_LIMIT_MAX'), clusterMode: true, redisOptions: { host: configService.get('REDIS_HOST'), port: configService.get('REDIS_PORT'), password: configService.get('REDIS_PASSWORD'), }, }), inject: [ConfigService], }), ], }) export class AppModule {} ``` ## Usage in Controllers ### Route-specific Rate Limiting ```typescript import { Controller, Get } from '@nestjs/common'; import { RateLimit } from 'nestjs-cluster-throttle'; @Controller('api') export class ApiController { @Get('limited') @RateLimit({ windowMs: 60 * 1000, // 1 minute max: 10, // 10 requests per minute message: 'Too many requests from this IP', statusCode: 429, }) limitedEndpoint() { return { message: 'This endpoint is rate limited' }; } @Get('public') publicEndpoint() { return { message: 'This endpoint uses global rate limits' }; } } ``` ### Skip Rate Limiting ```typescript import { Controller, Get } from '@nestjs/common'; import { SkipRateLimit } from 'nestjs-cluster-throttle'; @Controller('api') export class ApiController { @Get('health') @SkipRateLimit() healthCheck() { return { status: 'ok' }; } } ``` ## Advanced Configuration ### Custom Key Generator Use custom logic to generate rate limit keys: ```typescript RateLimitModule.forRoot({ windowMs: 15 * 60 * 1000, max: 100, keyGenerator: (request) => { // Rate limit by user ID instead of IP return request.user?.id || request.ip; }, }) ``` ### Skip Requests Conditionally ```typescript RateLimitModule.forRoot({ windowMs: 15 * 60 * 1000, max: 100, skip: (request) => { // Skip rate limiting for admin users return request.user?.role === 'admin'; }, }) ``` ### Custom Handler ```typescript RateLimitModule.forRoot({ windowMs: 15 * 60 * 1000, max: 100, handler: (request, response) => { // Custom handling when rate limit is exceeded response.status(429).json({ error: 'Rate limit exceeded', retryAfter: response.getHeader('X-RateLimit-Reset'), }); }, }) ``` ### Skip Successful Requests Only count failed requests towards the rate limit: ```typescript RateLimitModule.forRoot({ windowMs: 15 * 60 * 1000, max: 100, skipSuccessfulRequests: true, }) ``` ## Geo-blocking (IP-based Country Restrictions) ### Basic Geo-blocking Block or allow specific countries: ```typescript import { Controller, Get } from '@nestjs/common'; import { RateLimit } from 'nestjs-cluster-throttle'; @Controller('api') export class ApiController { @Get('us-only') @RateLimit({ windowMs: 60000, max: 100, geoLocation: { allowedCountries: ['US', 'CA'], // Only US and Canada message: 'This service is only available in North America', statusCode: 403, }, }) usOnlyEndpoint() { return { message: 'Welcome, North American user!' }; } @Get('block-countries') @RateLimit({ windowMs: 60000, max: 100, geoLocation: { blockedCountries: ['CN', 'RU'], // Block specific countries message: 'Access denied from your country', }, }) restrictedEndpoint() { return { message: 'Access granted' }; } } ``` ### Geo Providers Choose from multiple geo-location providers: ```typescript RateLimitModule.forRoot({ windowMs: 60000, max: 100, geoLocation: { provider: 'ip-api', // Options: 'internal', 'ip-api', 'custom' allowedCountries: ['US', 'GB', 'DE'], }, }) ``` #### Available Providers 1. **internal** (default) - Basic IP range checking, good for testing 2. **ip-api** - Free service from ip-api.com (45 req/min limit) 3. **custom** - Bring your own provider ### Custom Geo Provider ```typescript import { GeoLocationProvider, GeoLocationResult } from 'nestjs-cluster-throttle'; class MyGeoProvider implements GeoLocationProvider { async lookup(ip: string): Promise<GeoLocationResult | null> { // e.g., MaxMind GeoIP2, IPStack, etc. return { country: 'United States', countryCode: 'US', city: 'New York', lat: 40.7128, lon: -74.0060, }; } } RateLimitModule.forRoot({ windowMs: 60000, max: 100, geoLocation: { provider: 'custom', customProvider: new MyGeoProvider(), allowedCountries: ['US'], }, }) ``` ### Geo-block Callback Get notified when a request is geo-blocked: ```typescript @RateLimit({ windowMs: 60000, max: 100, geoLocation: { blockedCountries: ['XX'], onGeoBlock: (ip, country, request) => { console.log(`Blocked request from ${country} (${ip})`); // Log to analytics, notify admin, etc. }, }, }) ``` ### Global Geo-blocking Apply geo-restrictions globally: ```typescript RateLimitModule.forRoot({ windowMs: 60000, max: 100, geoLocation: { provider: 'ip-api', blockedCountries: ['XX', 'YY'], message: 'Service not available in your region', }, }) ``` ## Rate Limiting Strategies ### Fixed Window (Default) ```typescript RateLimitModule.forRoot({ windowMs: 60 * 1000, max: 100, strategy: 'fixed-window', }) ``` ### Token Bucket ```typescript RateLimitModule.forRoot({ windowMs: 60 * 1000, max: 100, strategy: 'token-bucket', burstCapacity: 150, // Allow bursts up to 150 requests fillRate: 100, // Refill 100 tokens per windowMs }) ``` ### Sliding Window ```typescript RateLimitModule.forRoot({ windowMs: 60 * 1000, max: 100, strategy: 'sliding-window', }) ``` ## Programmatic Usage Inject `RateLimitService` to check rate limits programmatically: ```typescript import { Injectable } from '@nestjs/common'; import { RateLimitService } from 'nestjs-cluster-throttle'; @Injectable() export class MyService { constructor(private rateLimitService: RateLimitService) {} async checkLimit(request: any) { const result = await this.rateLimitService.checkRateLimit(request, { windowMs: 60 * 1000, max: 10, }); if (!result.allowed) { throw new Error('Rate limit exceeded'); } return result; } async resetUserLimit(userId: string) { await this.rateLimitService.resetKey(userId); } async resetAllLimits() { await this.rateLimitService.resetAll(); } } ``` ## Configuration Options |Option|Type|Default|Description| |---|---|---|---| |`windowMs`|number|`900000` (15 min)|Time window in milliseconds| |`max`|number|`100`|Maximum number of requests per window| |`message`|string|`'Too Many Requests'`|Error message when limit is exceeded| |`statusCode`|number|`429`|HTTP status code when limit is exceeded| |`skipSuccessfulRequests`|boolean|`false`|Skip successful requests in counting| |`keyGenerator`|function|IP-based|Function to generate rate limit key| |`skip`|function|`undefined`|Function to conditionally skip rate limiting| |`handler`|function|`undefined`|Custom handler for rate limit exceeded| |`clusterMode`|boolean|`false`|Enable Redis for cluster mode| |`redisOptions`|object|`{}`|Redis connection options| |`strategy`|string|`'fixed-window'`|Rate limiting strategy| |`burstCapacity`|number|`undefined`|Burst capacity for token-bucket| |`fillRate`|number|`undefined`|Fill rate for token-bucket| |`geoLocation`|object|`undefined`|Geo-blocking configuration| ### Geo-Location Options |Option|Type|Default|Description| |---|---|---|---| |`provider`|string|`'internal'`|Geo provider: 'internal', 'ip-api', 'custom'| |`customProvider`|GeoLocationProvider|`undefined`|Custom geo provider implementation| |`allowedCountries`|string[]|`undefined`|ISO country codes to allow| |`blockedCountries`|string[]|`undefined`|ISO country codes to block| |`onGeoBlock`|function|`undefined`|Callback when request is geo-blocked| |`message`|string|Auto-generated|Error message for geo-blocked requests| |`statusCode`|number|`403`|HTTP status for geo-blocked requests| ### Redis Options |Option|Type|Default|Description| |---|---|---|---| |`host`|string|`'localhost'`|Redis host| |`port`|number|`6379`|Redis port| |`password`|string|`undefined`|Redis password| |`db`|number|`0`|Redis database number| |`keyPrefix`|string|`'rate-limit:'`|Key prefix for Redis| |`enableReadyCheck`|boolean|`true`|Enable ready check| ## Response Headers When rate limiting is active, the following headers are set: - `X-RateLimit-Limit`: Maximum requests allowed - `X-RateLimit-Remaining`: Requests remaining in current window - `X-RateLimit-Reset`: Unix timestamp when the rate limit resets ## Testing ```typescript import { Test } from '@nestjs/testing'; import { RateLimitModule, RateLimitService } from 'nestjs-cluster-throttle'; describe('RateLimitService', () => { let service: RateLimitService; beforeEach(async () => { const module = await Test.createTestingModule({ imports: [ RateLimitModule.forRoot({ windowMs: 1000, max: 5, }), ], }).compile(); service = module.get<RateLimitService>(RateLimitService); }); it('should limit requests', async () => { const request = { ip: '127.0.0.1', method: 'GET', url: '/test' }; // First 5 requests should be allowed for (let i = 0; i < 5; i++) { const result = await service.checkRateLimit(request); expect(result.allowed).toBe(true); } // 6th request should be blocked const result = await service.checkRateLimit(request); expect(result.allowed).toBe(false); }); }); ``` ## Best Practices 1. **Use Redis in production**: For multi-instance deployments, always use Redis to ensure consistent rate limiting across instances. 2. **Choose appropriate windows**: Shorter windows (1-5 minutes) for strict limits, longer windows (15-60 minutes) for general API protection. 3. **Set realistic limits**: Consider your API's capacity and user needs. Start conservative and adjust based on monitoring. 4. **Use custom key generators**: For authenticated APIs, rate limit by user ID rather than IP to prevent shared IP issues. 5. **Monitor rate limit hits**: Track how often users hit limits to adjust thresholds appropriately. 6. **Implement skip logic**: Exempt health checks, webhooks, or trusted services from rate limiting. ## Performance Considerations - **Memory Store**: Fast but not suitable for cluster mode. Memory usage grows with unique IPs. - **Redis Store**: Adds minimal latency (~1-2ms) but enables cluster mode and reduces memory usage. - **Lua Scripts**: Redis operations use Lua scripts for atomic operations, ensuring accuracy. ## Troubleshooting ### Rate limiting not working in cluster mode Make sure Redis is properly configured and accessible from all instances: ```typescript RateLimitModule.forRoot({ clusterMode: true, redisOptions: { host: 'redis-host', port: 6379, enableReadyCheck: true, }, }) ``` ### Rate limits reset unexpectedly Check Redis persistence settings. If Redis restarts without persistence, all rate limit data is lost. ## 🧑‍💻 Contributing We love contributions! Found a bug or have an idea? Open an issue or submit a PR. --- ## 📜 License This project is licensed under the MIT License. See the LICENSE file for details.