├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── .eslintignore ├── .npmrc ├── .czrc ├── renovate.json ├── .vscode ├── settings.json └── launch.json ├── nest-cli.json ├── .prettierrc ├── tsconfig.build.json ├── .lintstagedrc ├── src ├── hash.ts ├── utilities.ts ├── throttler.constants.ts ├── index.ts ├── throttler.exception.ts ├── throttler-storage.interface.ts ├── throttler-storage-record.interface.ts ├── throttler-storage-options.interface.ts ├── throttler.providers.ts ├── throttler.guard.interface.ts ├── throttler.module.ts ├── throttler.decorator.ts ├── throttler-module-options.interface.ts ├── throttler.service.ts ├── throttler.guard.ts └── throttler.guard.spec.ts ├── test ├── utility │ ├── hash.ts │ └── httpromise.ts ├── jest-e2e.json ├── app │ ├── app.service.ts │ ├── controllers │ │ ├── default.controller.ts │ │ ├── limit.controller.ts │ │ ├── app.controller.ts │ │ └── controller.module.ts │ ├── main.ts │ └── app.module.ts ├── error-message │ ├── custom-error-message.controller.ts │ ├── app.module.ts │ └── custom-error-message.e2e-spec.ts ├── multi │ ├── multi-throttler.controller.ts │ ├── app.module.ts │ └── multi-throttler.e2e-spec.ts ├── function-overrides │ ├── function-overrides-throttler.controller.ts │ ├── app.module.ts │ └── function-overrides-throttler.e2e-spec.ts └── controller.e2e-spec.ts ├── .changeset ├── config.json └── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── Feature_request.yml │ ├── Regression.yml │ └── Bug_report.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ └── ci.yml ├── tsconfig.json ├── .gitignore ├── .commitlintrc.json ├── LICENSE ├── .eslintrc.js ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint -g .commitlintrc.json --edit $1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | message="chore(release): %s :tada:" -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "ratelimit" 4 | ] 5 | } -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": [ 3 | "prettier --write", 4 | "eslint --ext ts" 5 | ], 6 | "*.{md,html,json,js}": [ 7 | "prettier --write" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | 3 | export function sha256(text: string): string { 4 | return crypto.createHash('sha256').update(text).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /test/utility/hash.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | 3 | export function md5(text: string): string { 4 | return crypto.createHash('md5').update(text).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "..", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/app/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | success() { 6 | return { success: true }; 7 | } 8 | 9 | ignored() { 10 | return { ignored: true }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | ## To encourage contributors to use issue templates, we don't allow blank issues 2 | blank_issues_enabled: false 3 | 4 | contact_links: 5 | - name: "\u2753 Discord Community of NestJS" 6 | url: "https://discord.gg/NestJS" 7 | about: "Please ask support questions or discuss suggestions/enhancements here." 8 | -------------------------------------------------------------------------------- /src/utilities.ts: -------------------------------------------------------------------------------- 1 | export const seconds = (howMany: number) => howMany * 1000; 2 | export const minutes = (howMany: number) => 60 * howMany * seconds(1); 3 | export const hours = (howMany: number) => 60 * howMany * minutes(1); 4 | export const days = (howMany: number) => 24 * howMany * hours(1); 5 | export const weeks = (howMany: number) => 7 * howMany * days(1); 6 | -------------------------------------------------------------------------------- /test/app/controllers/default.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from '../app.service'; 3 | 4 | @Controller('default') 5 | export class DefaultController { 6 | constructor(private readonly appService: AppService) {} 7 | @Get() 8 | getDefault() { 9 | return this.appService.success(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "incremental": true 12 | }, 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /test/app/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ExpressAdapter } from '@nestjs/platform-express'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create( 7 | AppModule, 8 | new ExpressAdapter(), 9 | // new FastifyAdapter(), 10 | ); 11 | await app.listen(3000); 12 | } 13 | bootstrap(); 14 | -------------------------------------------------------------------------------- /src/throttler.constants.ts: -------------------------------------------------------------------------------- 1 | export const THROTTLER_LIMIT = 'THROTTLER:LIMIT'; 2 | export const THROTTLER_TTL = 'THROTTLER:TTL'; 3 | export const THROTTLER_TRACKER = 'THROTTLER:TRACKER'; 4 | export const THROTTLER_BLOCK_DURATION = 'THROTTLER:BLOCK_DURATION'; 5 | export const THROTTLER_KEY_GENERATOR = 'THROTTLER:KEY_GENERATOR'; 6 | export const THROTTLER_OPTIONS = 'THROTTLER:MODULE_OPTIONS'; 7 | export const THROTTLER_SKIP = 'THROTTLER:SKIP'; 8 | -------------------------------------------------------------------------------- /test/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ThrottlerGuard } from '../../src'; 4 | import { ControllerModule } from './controllers/controller.module'; 5 | 6 | @Module({ 7 | imports: [ControllerModule], 8 | providers: [ 9 | { 10 | provide: APP_GUARD, 11 | useClass: ThrottlerGuard, 12 | }, 13 | ], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './throttler-module-options.interface'; 2 | export * from './throttler-storage.interface'; 3 | export * from './throttler.decorator'; 4 | export * from './throttler.exception'; 5 | export * from './throttler.guard'; 6 | export * from './throttler.guard.interface'; 7 | export * from './throttler.module'; 8 | export { getOptionsToken, getStorageToken } from './throttler.providers'; 9 | export * from './throttler.service'; 10 | export * from './utilities'; 11 | -------------------------------------------------------------------------------- /test/error-message/custom-error-message.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle } from '../../src'; 3 | 4 | @Controller() 5 | export class CustomErrorMessageController { 6 | @SkipThrottle({ other: true }) 7 | @Get('default') 8 | defaultRoute() { 9 | return { success: true }; 10 | } 11 | 12 | @SkipThrottle({ default: true }) 13 | @Get('other') 14 | otherRoute() { 15 | return { success: true }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach", 9 | "port": 9229, 10 | "request": "attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "type": "pwa-node" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/throttler.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export const throttlerMessage = 'ThrottlerException: Too Many Requests'; 4 | 5 | /** 6 | * Throws a HttpException with a 429 status code, indicating that too many 7 | * requests were being fired within a certain time window. 8 | * @publicApi 9 | */ 10 | export class ThrottlerException extends HttpException { 11 | constructor(message?: string) { 12 | super(`${message || throttlerMessage}`, HttpStatus.TOO_MANY_REQUESTS); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | dist 3 | node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # VIM 37 | *.swp 38 | *.swo 39 | -------------------------------------------------------------------------------- /test/multi/multi-throttler.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle } from '../../src'; 3 | 4 | @Controller() 5 | export class MultiThrottlerController { 6 | @Get() 7 | simpleRoute() { 8 | return { success: true }; 9 | } 10 | 11 | @SkipThrottle({ short: true }) 12 | @Get('skip-short') 13 | skipShort() { 14 | return { success: true }; 15 | } 16 | 17 | @SkipThrottle({ default: true, long: true }) 18 | @Get('skip-default-and-long') 19 | skipDefAndLong() { 20 | return { success: true }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/throttler-storage.interface.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 2 | 3 | export interface ThrottlerStorage { 4 | /** 5 | * Increment the amount of requests for a given record. The record will 6 | * automatically be removed from the storage once its TTL has been reached. 7 | */ 8 | increment( 9 | key: string, 10 | ttl: number, 11 | limit: number, 12 | blockDuration: number, 13 | throttlerName: string, 14 | ): Promise; 15 | } 16 | 17 | export const ThrottlerStorage = Symbol('ThrottlerStorage'); 18 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-angular"], 3 | "rules": { 4 | "subject-case": [ 5 | 2, 6 | "always", 7 | ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case"] 8 | ], 9 | "type-enum": [ 10 | 2, 11 | "always", 12 | [ 13 | "build", 14 | "chore", 15 | "ci", 16 | "docs", 17 | "feat", 18 | "fix", 19 | "perf", 20 | "refactor", 21 | "revert", 22 | "style", 23 | "test", 24 | "sample" 25 | ] 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/throttler-storage-record.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ThrottlerStorageRecord { 2 | /** 3 | * Amount of requests done by a specific user (partially based on IP). 4 | */ 5 | totalHits: number; 6 | 7 | /** 8 | * Amount of seconds when the `ttl` should expire. 9 | */ 10 | timeToExpire: number; 11 | 12 | /** 13 | * Define whether the request is blocked or not. 14 | */ 15 | isBlocked: boolean; 16 | 17 | /** 18 | * Amount of seconds when the `totalHits` should expire. 19 | */ 20 | timeToBlockExpire: number; 21 | } 22 | 23 | export const ThrottlerStorageRecord = Symbol('ThrottlerStorageRecord'); 24 | -------------------------------------------------------------------------------- /src/throttler-storage-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ThrottlerStorageOptions { 2 | /** 3 | * Amount of requests done by a specific user (partially based on IP). 4 | */ 5 | totalHits: Map; 6 | 7 | /** 8 | * Unix timestamp in milliseconds that indicates `ttl` lifetime. 9 | */ 10 | expiresAt: number; 11 | 12 | /** 13 | * Define whether the request is blocked or not. 14 | */ 15 | isBlocked: boolean; 16 | 17 | /** 18 | * Unix timestamp in milliseconds when the `totalHits` expire. 19 | */ 20 | blockExpiresAt: number; 21 | } 22 | 23 | export const ThrottlerStorageOptions = Symbol('ThrottlerStorageOptions'); 24 | -------------------------------------------------------------------------------- /test/app/controllers/limit.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { Throttle, seconds } from '../../../src'; 3 | import { AppService } from '../app.service'; 4 | 5 | @Throttle({ default: { limit: 2, ttl: seconds(10), blockDuration: seconds(5) } }) 6 | @Controller('limit') 7 | export class LimitController { 8 | constructor(private readonly appService: AppService) {} 9 | @Get() 10 | getThrottled() { 11 | return this.appService.success(); 12 | } 13 | 14 | @Throttle({ default: { limit: 5, ttl: seconds(10), blockDuration: seconds(15) } }) 15 | @Get('higher') 16 | getHigher() { 17 | return this.appService.success(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/app/controllers/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle, Throttle, seconds } from '../../../src'; 3 | import { AppService } from '../app.service'; 4 | 5 | @Controller() 6 | @Throttle({ default: { limit: 2, ttl: seconds(10) } }) 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get() 11 | async test() { 12 | return this.appService.success(); 13 | } 14 | 15 | @Get('ignored') 16 | @SkipThrottle() 17 | async ignored() { 18 | return this.appService.ignored(); 19 | } 20 | 21 | @Get('ignore-user-agents') 22 | async ignoreUserAgents() { 23 | return this.appService.ignored(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/function-overrides/function-overrides-throttler.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle } from '../../src'; 3 | 4 | @Controller() 5 | export class FunctionOverridesThrottlerController { 6 | @SkipThrottle({ custom: true }) 7 | @Get() 8 | simpleRoute() { 9 | return { success: true }; 10 | } 11 | 12 | @SkipThrottle({ custom: true }) 13 | @Get('1') 14 | simpleRouteOne() { 15 | return { success: true }; 16 | } 17 | 18 | @SkipThrottle({ default: true }) 19 | @Get('custom') 20 | simpleRouteTwo() { 21 | return { success: true }; 22 | } 23 | 24 | @SkipThrottle({ default: true }) 25 | @Get('custom/1') 26 | simpleRouteThrww() { 27 | return { success: true }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/app/controllers/controller.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ThrottlerModule, seconds } from '../../../src'; 3 | import { AppService } from '../app.service'; 4 | import { AppController } from './app.controller'; 5 | import { DefaultController } from './default.controller'; 6 | import { LimitController } from './limit.controller'; 7 | 8 | @Module({ 9 | imports: [ 10 | ThrottlerModule.forRoot([ 11 | { 12 | limit: 5, 13 | ttl: seconds(60), 14 | blockDuration: seconds(20), 15 | ignoreUserAgents: [/throttler-test/g], 16 | }, 17 | ]), 18 | ], 19 | controllers: [AppController, DefaultController, LimitController], 20 | providers: [AppService], 21 | }) 22 | export class ControllerModule {} 23 | -------------------------------------------------------------------------------- /test/multi/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ThrottlerGuard, ThrottlerModule, seconds, minutes } from '../../src'; 4 | import { MultiThrottlerController } from './multi-throttler.controller'; 5 | 6 | @Module({ 7 | imports: [ 8 | ThrottlerModule.forRoot([ 9 | { 10 | ttl: seconds(5), 11 | limit: 2, 12 | }, 13 | { 14 | name: 'long', 15 | ttl: minutes(1), 16 | limit: 5, 17 | }, 18 | { 19 | name: 'short', 20 | limit: 1, 21 | ttl: seconds(1), 22 | }, 23 | ]), 24 | ], 25 | controllers: [MultiThrottlerController], 26 | providers: [ 27 | { 28 | provide: APP_GUARD, 29 | useClass: ThrottlerGuard, 30 | }, 31 | ], 32 | }) 33 | export class MultiThrottlerAppModule {} 34 | -------------------------------------------------------------------------------- /test/error-message/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { seconds, ThrottlerGuard, ThrottlerModule } from '../../src'; 4 | import { CustomErrorMessageController } from './custom-error-message.controller'; 5 | 6 | @Module({ 7 | imports: [ 8 | ThrottlerModule.forRoot({ 9 | errorMessage: (context, throttlerLimitDetail) => 10 | `${context.getClass().name}-${ 11 | context.getHandler().name 12 | } ${throttlerLimitDetail.tracker} ${throttlerLimitDetail.totalHits}`, 13 | throttlers: [ 14 | { 15 | name: 'default', 16 | ttl: seconds(3), 17 | limit: 2, 18 | }, 19 | { 20 | name: 'other', 21 | ttl: seconds(3), 22 | limit: 2, 23 | }, 24 | ], 25 | }), 26 | ], 27 | controllers: [CustomErrorMessageController], 28 | providers: [ 29 | { 30 | provide: APP_GUARD, 31 | useClass: ThrottlerGuard, 32 | }, 33 | ], 34 | }) 35 | export class CustomErrorMessageThrottlerModule {} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2019-2024 Jay McDoniel, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/throttler.providers.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@nestjs/common'; 2 | import { ThrottlerModuleOptions } from './throttler-module-options.interface'; 3 | import { ThrottlerStorage } from './throttler-storage.interface'; 4 | import { THROTTLER_OPTIONS } from './throttler.constants'; 5 | import { ThrottlerStorageService } from './throttler.service'; 6 | 7 | export function createThrottlerProviders(options: ThrottlerModuleOptions): Provider[] { 8 | return [ 9 | { 10 | provide: THROTTLER_OPTIONS, 11 | useValue: options, 12 | }, 13 | ]; 14 | } 15 | 16 | export const ThrottlerStorageProvider = { 17 | provide: ThrottlerStorage, 18 | useFactory: (options: ThrottlerModuleOptions) => { 19 | return !Array.isArray(options) && options.storage 20 | ? options.storage 21 | : new ThrottlerStorageService(); 22 | }, 23 | inject: [THROTTLER_OPTIONS], 24 | }; 25 | 26 | /** 27 | * A utility function for getting the options injection token 28 | * @publicApi 29 | */ 30 | export const getOptionsToken = () => THROTTLER_OPTIONS; 31 | 32 | /** 33 | * A utility function for getting the storage injection token 34 | * @publicApi 35 | */ 36 | export const getStorageToken = () => ThrottlerStorage; 37 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | sourceType: 'module', 5 | }, 6 | plugins: ['@typescript-eslint/eslint-plugin'], 7 | extends: [ 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | rules: { 18 | '@typescript-eslint/no-unused-vars': [ 19 | 'error', 20 | { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, 21 | ], 22 | '@typescript-eslint/interface-name-prefix': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/explicit-module-boundary-types': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | 'comma-dangle': ['warn', 'always-multiline'], 27 | 'max-len': [ 28 | 'warn', 29 | { 30 | code: 100, 31 | tabWidth: 2, 32 | ignoreComments: false, 33 | ignoreTrailingComments: true, 34 | ignoreUrls: true, 35 | ignoreStrings: true, 36 | ignoreTemplateLiterals: true, 37 | ignoreRegExpLiterals: true, 38 | }, 39 | ], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | Please check if your PR fulfills the following requirements: 3 | 4 | - [ ] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md 5 | - [ ] Tests for the changes have been added (for bug fixes / features) 6 | - [ ] Docs have been added / updated (for bug fixes / features) 7 | 8 | 9 | ## PR Type 10 | What kind of change does this PR introduce? 11 | 12 | 13 | - [ ] Bugfix 14 | - [ ] Feature 15 | - [ ] Code style update (formatting, local variables) 16 | - [ ] Refactoring (no functional changes, no api changes) 17 | - [ ] Build related changes 18 | - [ ] CI related changes 19 | - [ ] Other... Please describe: 20 | 21 | ## What is the current behavior? 22 | 23 | 24 | Issue Number: N/A 25 | 26 | 27 | ## What is the new behavior? 28 | 29 | 30 | ## Does this PR introduce a breaking change? 31 | - [ ] Yes 32 | - [ ] No 33 | 34 | 35 | 36 | 37 | ## Other information 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@master 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node.js 20.x 20 | uses: actions/setup-node@master 21 | with: 22 | node-version: 24.x 23 | 24 | - name: Install PNPM 25 | run: npm i -g pnpm 26 | 27 | - name: Install Dependencies 28 | run: pnpm i 29 | 30 | - name: Build Projects 31 | run: pnpm build 32 | 33 | - name: Create Release Pull Request or Publish to npm 34 | id: changesets 35 | uses: changesets/action@master 36 | with: 37 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 38 | publish: yarn release 39 | commit: "chore: version packages" 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /test/function-overrides/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ThrottlerGuard, ThrottlerModule, seconds } from '../../src'; 4 | import { FunctionOverridesThrottlerController } from './function-overrides-throttler.controller'; 5 | import { md5 } from '../utility/hash'; 6 | import assert = require('assert'); 7 | 8 | @Module({ 9 | imports: [ 10 | ThrottlerModule.forRoot([ 11 | { 12 | name: 'default', 13 | ttl: seconds(3), 14 | limit: 2, 15 | }, 16 | { 17 | name: 'custom', 18 | ttl: seconds(3), 19 | limit: 2, 20 | getTracker: () => 'customTrackerString', 21 | generateKey: (context: ExecutionContext, trackerString: string, throttlerName: string) => { 22 | // check if tracker string is passed correctly 23 | assert(trackerString === 'customTrackerString'); 24 | // use the same key for all endpoints 25 | return md5(`${throttlerName}-${trackerString}`); 26 | }, 27 | }, 28 | ]), 29 | ], 30 | controllers: [FunctionOverridesThrottlerController], 31 | providers: [ 32 | { 33 | provide: APP_GUARD, 34 | useClass: ThrottlerGuard, 35 | }, 36 | ], 37 | }) 38 | export class FunctionOverridesThrottlerModule {} 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'master' 7 | push: 8 | branches: 9 | - '*' 10 | schedule: 11 | - cron: '0 0 * * *' 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v6 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v6 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: install pnpm 28 | run: npm i -g pnpm@^9 29 | - name: install deps 30 | run: pnpm i 31 | - name: lint 32 | run: pnpm lint 33 | - name: build 34 | run: pnpm build 35 | - name: test 36 | run: pnpm test:cov 37 | - name: E2E test 38 | run: pnpm test:e2e 39 | 40 | auto-merge: 41 | needs: test 42 | if: contains(github.event.pull_request.user.login, 'dependabot') || contains(github.event.pull_request.user.login, 'renovate') 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: automerge 46 | uses: pascalgn/automerge-action@v0.16.4 47 | env: 48 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 49 | MERGE_LABELS: '' 50 | MERGE_METHOD: rebase 51 | -------------------------------------------------------------------------------- /test/utility/httpromise.ts: -------------------------------------------------------------------------------- 1 | import { request } from 'http'; 2 | 3 | type HttpMethods = 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; 4 | 5 | export function httPromise( 6 | url: string, 7 | method: HttpMethods = 'GET', 8 | headers: Record = {}, 9 | body?: Record, 10 | ): Promise<{ data: any; headers: Record; status: number }> { 11 | return new Promise((resolve, reject) => { 12 | const req = request(url, (res) => { 13 | res.setEncoding('utf-8'); 14 | let data = ''; 15 | res.on('data', (chunk) => { 16 | data += chunk; 17 | }); 18 | res.on('end', () => { 19 | return resolve({ 20 | data: JSON.parse(data), 21 | headers: res.headers, 22 | status: res.statusCode, 23 | }); 24 | }); 25 | res.on('error', (err) => { 26 | return reject({ 27 | data: err, 28 | headers: res.headers, 29 | status: res.statusCode, 30 | }); 31 | }); 32 | }); 33 | req.method = method; 34 | 35 | Object.keys(headers).forEach((key) => { 36 | req.setHeader(key, headers[key]); 37 | }); 38 | 39 | switch (method) { 40 | case 'GET': 41 | break; 42 | case 'POST': 43 | case 'PUT': 44 | case 'PATCH': 45 | req.setHeader('Content-Type', 'application/json'); 46 | req.setHeader('Content-Length', Buffer.byteLength(Buffer.from(JSON.stringify(body)))); 47 | req.write(Buffer.from(JSON.stringify(body))); 48 | break; 49 | case 'DELETE': 50 | break; 51 | default: 52 | reject(new Error('Invalid HTTP method')); 53 | break; 54 | } 55 | req.end(); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | Any and all contributions are welcome! This is a decently sized project with a good scoped of functionality. 4 | 5 | ## How to Contribute 6 | 7 | 1. Create a fork of the repository 8 | 2. Clone the code to your local machine 9 | 3. Create a new branch with the feature you are working on (e.g. WebSocket-Interceptor) or with the issue number (e.g. issue/42) 10 | 4. Run `pnpm i` 11 | 5. Implement your changes, ensure tests are still passing, or add tests if it is a new feature 12 | 6. Push back to your version on GitHub 13 | 7. Raise a Pull Request to the main repository 14 | 15 | ## Development 16 | 17 | All the source code is in `src` as expected. Most of the code should be rather self documented. 18 | 19 | ## Testing 20 | 21 | To run a basic dev server you can use `start:dev` to run `nodemon` and `ts-node`. All tests should be running through `jest` using `test:e2e` otherwise. 22 | 23 | If you need to run tests for a specific context, use `pnpm test:e2e ` (one of: controller, ws, gql) e.g. `pnpm test:e2e controller` will run the e2e tests for the HTTP guard. 24 | 25 | ## Commits 26 | 27 | We are using [Conventional Commit](https://github.com/conventional-changelog/commitlint) to help keep commit messages aligned as development continues. The easiest way to get acquainted with what the commit should look like is to run `yarn commit` which will use the `git-cz` cli and walk you through the steps of committing. Once you've made your commit, prettier and eslint will run and ensure that the new code is up to the standards we have in place. 28 | 29 | ## Issues 30 | 31 | Please raise an issue, or discuss with me [via email](mailto:me@jaymcdoniel.dev) or [Discord](https://discordapp.com) (PerfectOrphan31#6003) before opening a Pull Request so we can see if they align with the goals of the project. 32 | -------------------------------------------------------------------------------- /test/error-message/custom-error-message.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { AbstractHttpAdapter } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test } from '@nestjs/testing'; 6 | import { request, spec } from 'pactum'; 7 | import { CustomErrorMessageThrottlerModule } from './app.module'; 8 | 9 | jest.setTimeout(10000); 10 | 11 | describe.each` 12 | adapter | name 13 | ${ExpressAdapter} | ${'express'} 14 | ${FastifyAdapter} | ${'fastify'} 15 | `( 16 | 'Function-Overrides-Throttler Named Usage - $name', 17 | ({ adapter }: { adapter: Type }) => { 18 | let app: INestApplication; 19 | beforeAll(async () => { 20 | const modRef = await Test.createTestingModule({ 21 | imports: [CustomErrorMessageThrottlerModule], 22 | }).compile(); 23 | app = modRef.createNestApplication(new adapter()); 24 | await app.listen(0); 25 | request.setBaseUrl(await app.getUrl()); 26 | }); 27 | afterAll(async () => { 28 | await app.close(); 29 | }); 30 | 31 | describe.each` 32 | route | errorMessage 33 | ${'default'} | ${'CustomErrorMessageController-defaultRoute ::1 3'} 34 | ${'other'} | ${'CustomErrorMessageController-otherRoute ::1 3'} 35 | `( 36 | 'Custom-error-message Route - $route', 37 | ({ route, errorMessage }: { route: string; errorMessage: string }) => { 38 | it('should receive a custom exception', async () => { 39 | const limit = 2; 40 | for (let i = 0; i < limit; i++) { 41 | await spec().get(`/${route}`).expectStatus(200); 42 | } 43 | 44 | await spec().get(`/${route}`).expectStatus(429).expectBodyContains(errorMessage); 45 | }); 46 | }, 47 | ); 48 | }, 49 | ); 50 | -------------------------------------------------------------------------------- /src/throttler.guard.interface.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 3 | import { 4 | ThrottlerGenerateKeyFunction, 5 | ThrottlerGetTrackerFunction, 6 | ThrottlerOptions, 7 | } from './throttler-module-options.interface'; 8 | 9 | /** 10 | * Interface describing the details of a rate limit applied by the ThrottlerGuard. 11 | */ 12 | export interface ThrottlerLimitDetail extends ThrottlerStorageRecord { 13 | /** 14 | * Time to live for the rate limit, in seconds. After this time has elapsed, the rate limit is removed. 15 | */ 16 | ttl: number; 17 | 18 | /** 19 | * Maximum number of requests allowed within the time period defined by `ttl`. 20 | */ 21 | limit: number; 22 | 23 | /** 24 | * Unique identifier for the rate limit. This field is used to group requests that share the same rate limit. 25 | */ 26 | key: string; 27 | 28 | /** 29 | * A string representation of the tracker object used to keep track of the incoming requests and apply the rate limit. 30 | */ 31 | tracker: string; 32 | } 33 | 34 | export interface ThrottlerRequest { 35 | /** 36 | * Interface describing details about the current request pipeline. 37 | */ 38 | context: ExecutionContext; 39 | 40 | /** 41 | * The amount of requests that are allowed within the ttl's time window. 42 | */ 43 | limit: number; 44 | 45 | /** 46 | * The number of milliseconds that each request will last in storage. 47 | */ 48 | ttl: number; 49 | 50 | /** 51 | * Incoming options of the throttler. 52 | */ 53 | throttler: ThrottlerOptions; 54 | 55 | /** 56 | * The number of milliseconds the request will be blocked. 57 | */ 58 | blockDuration: number; 59 | 60 | /** 61 | * A method to override the default tracker string. 62 | */ 63 | getTracker: ThrottlerGetTrackerFunction; 64 | 65 | /** 66 | * A method to override the default key generator. 67 | */ 68 | generateKey: ThrottlerGenerateKeyFunction; 69 | } 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature Request" 2 | description: "I have a suggestion \U0001F63B!" 3 | labels: ["feature"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## :warning: We use GitHub Issues to track bug reports, feature requests and regressions 9 | 10 | If you are not sure that your issue is a bug, you could: 11 | 12 | - use our [Discord community](https://discord.gg/NestJS) 13 | - use [StackOverflow using the tag `nestjs`](https://stackoverflow.com/questions/tagged/nestjs) 14 | - If it's just a quick question you can ping [our Twitter](https://twitter.com/nestframework) 15 | 16 | --- 17 | 18 | - type: checkboxes 19 | attributes: 20 | label: "Is there an existing issue that is already proposing this?" 21 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting" 22 | options: 23 | - label: "I have searched the existing issues" 24 | required: true 25 | 26 | - type: textarea 27 | validations: 28 | required: true 29 | attributes: 30 | label: "Is your feature request related to a problem? Please describe it" 31 | description: "A clear and concise description of what the problem is" 32 | placeholder: | 33 | I have an issue when ... 34 | 35 | - type: textarea 36 | validations: 37 | required: true 38 | attributes: 39 | label: "Describe the solution you'd like" 40 | description: "A clear and concise description of what you want to happen. Add any considered drawbacks" 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Teachability, documentation, adoption, migration strategy" 45 | description: "If you can, explain how users will be able to use this and possibly write out a version the docs. Maybe a screenshot or design?" 46 | 47 | - type: textarea 48 | validations: 49 | required: true 50 | attributes: 51 | label: "What is the motivation / use case for changing the behavior?" 52 | description: "Describe the motivation or the concrete use case" 53 | -------------------------------------------------------------------------------- /src/throttler.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Provider, Global } from '@nestjs/common'; 2 | import { 3 | ThrottlerModuleOptions, 4 | ThrottlerAsyncOptions, 5 | ThrottlerOptionsFactory, 6 | } from './throttler-module-options.interface'; 7 | import { THROTTLER_OPTIONS } from './throttler.constants'; 8 | import { createThrottlerProviders, ThrottlerStorageProvider } from './throttler.providers'; 9 | 10 | /** 11 | * @publicApi 12 | */ 13 | @Global() 14 | @Module({}) 15 | export class ThrottlerModule { 16 | /** 17 | * Register the module synchronously. 18 | */ 19 | static forRoot(options: ThrottlerModuleOptions = []): DynamicModule { 20 | const providers = [...createThrottlerProviders(options), ThrottlerStorageProvider]; 21 | return { 22 | module: ThrottlerModule, 23 | providers, 24 | exports: providers, 25 | }; 26 | } 27 | 28 | /** 29 | * Register the module asynchronously. 30 | */ 31 | static forRootAsync(options: ThrottlerAsyncOptions): DynamicModule { 32 | const providers = [...this.createAsyncProviders(options), ThrottlerStorageProvider]; 33 | return { 34 | module: ThrottlerModule, 35 | imports: options.imports || [], 36 | providers, 37 | exports: providers, 38 | }; 39 | } 40 | 41 | private static createAsyncProviders(options: ThrottlerAsyncOptions): Provider[] { 42 | if (options.useExisting || options.useFactory) { 43 | return [this.createAsyncOptionsProvider(options)]; 44 | } 45 | return [ 46 | this.createAsyncOptionsProvider(options), 47 | { 48 | provide: options.useClass, 49 | useClass: options.useClass, 50 | }, 51 | ]; 52 | } 53 | 54 | private static createAsyncOptionsProvider(options: ThrottlerAsyncOptions): Provider { 55 | if (options.useFactory) { 56 | return { 57 | provide: THROTTLER_OPTIONS, 58 | useFactory: options.useFactory, 59 | inject: options.inject || [], 60 | }; 61 | } 62 | return { 63 | provide: THROTTLER_OPTIONS, 64 | useFactory: async (optionsFactory: ThrottlerOptionsFactory) => 65 | await optionsFactory.createThrottlerOptions(), 66 | inject: [options.useExisting || options.useClass], 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Regression.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4A5 Regression" 2 | description: "Report an unexpected behavior while upgrading your Nest application!" 3 | labels: ["needs triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## :warning: We use GitHub Issues to track bug reports, feature requests and regressions 9 | 10 | If you are not sure that your issue is a bug, you could: 11 | 12 | - use our [Discord community](https://discord.gg/NestJS) 13 | - use [StackOverflow using the tag `nestjs`](https://stackoverflow.com/questions/tagged/nestjs) 14 | - If it's just a quick question you can ping [our Twitter](https://twitter.com/nestframework) 15 | 16 | **NOTE:** You don't need to answer questions that you know that aren't relevant. 17 | 18 | --- 19 | 20 | - type: checkboxes 21 | attributes: 22 | label: "Did you read the migration guide?" 23 | description: "Check out the [migration guide here](https://docs.nestjs.com/migration-guide)!" 24 | options: 25 | - label: "I have read the whole migration guide" 26 | required: false 27 | 28 | - type: checkboxes 29 | attributes: 30 | label: "Is there an existing issue that is already proposing this?" 31 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting" 32 | options: 33 | - label: "I have searched the existing issues" 34 | required: true 35 | 36 | - type: input 37 | attributes: 38 | label: "Potential Commit/PR that introduced the regression" 39 | description: "If you have time to investigate, what PR/date/version introduced this issue" 40 | placeholder: "PR #123 or commit 5b3c4a4" 41 | 42 | - type: input 43 | attributes: 44 | label: "Versions" 45 | description: "From which version of `@nestjs/throttler` to which version you are upgrading" 46 | placeholder: "8.1.0 -> 8.1.3" 47 | 48 | - type: textarea 49 | validations: 50 | required: true 51 | attributes: 52 | label: "Describe the regression" 53 | description: "A clear and concise description of what the regression is" 54 | 55 | - type: textarea 56 | attributes: 57 | label: "Minimum reproduction code" 58 | description: | 59 | Please share a git repo, a gist, or step-by-step instructions. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction) 60 | **Tip:** If you leave a minimum repository, we will understand your issue faster! 61 | value: | 62 | ```ts 63 | 64 | ``` 65 | 66 | - type: textarea 67 | validations: 68 | required: true 69 | attributes: 70 | label: "Expected behavior" 71 | description: "A clear and concise description of what you expected to happend (or code)" 72 | 73 | - type: textarea 74 | attributes: 75 | label: "Other" 76 | description: | 77 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 78 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 79 | -------------------------------------------------------------------------------- /src/throttler.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { 3 | Resolvable, 4 | ThrottlerGenerateKeyFunction, 5 | ThrottlerGetTrackerFunction, 6 | } from './throttler-module-options.interface'; 7 | import { 8 | THROTTLER_BLOCK_DURATION, 9 | THROTTLER_KEY_GENERATOR, 10 | THROTTLER_LIMIT, 11 | THROTTLER_SKIP, 12 | THROTTLER_TRACKER, 13 | THROTTLER_TTL, 14 | } from './throttler.constants'; 15 | import { getOptionsToken, getStorageToken } from './throttler.providers'; 16 | 17 | interface ThrottlerMethodOrControllerOptions { 18 | limit?: Resolvable; 19 | ttl?: Resolvable; 20 | blockDuration?: Resolvable; 21 | getTracker?: ThrottlerGetTrackerFunction; 22 | generateKey?: ThrottlerGenerateKeyFunction; 23 | } 24 | 25 | function setThrottlerMetadata( 26 | target: any, 27 | options: Record, 28 | ): void { 29 | for (const name in options) { 30 | Reflect.defineMetadata(THROTTLER_TTL + name, options[name].ttl, target); 31 | Reflect.defineMetadata(THROTTLER_LIMIT + name, options[name].limit, target); 32 | Reflect.defineMetadata(THROTTLER_BLOCK_DURATION + name, options[name].blockDuration, target); 33 | Reflect.defineMetadata(THROTTLER_TRACKER + name, options[name].getTracker, target); 34 | Reflect.defineMetadata(THROTTLER_KEY_GENERATOR + name, options[name].generateKey, target); 35 | } 36 | } 37 | 38 | /** 39 | * Adds metadata to the target which will be handled by the ThrottlerGuard to 40 | * handle incoming requests based on the given metadata. 41 | * @example @Throttle({ default: { limit: 2, ttl: 10 }}) 42 | * @example @Throttle({default: { limit: () => 20, ttl: () => 60 }}) 43 | * @publicApi 44 | */ 45 | export const Throttle = ( 46 | options: Record, 47 | ): MethodDecorator & ClassDecorator => { 48 | return ( 49 | target: any, 50 | propertyKey?: string | symbol, 51 | descriptor?: TypedPropertyDescriptor, 52 | ) => { 53 | if (descriptor) { 54 | setThrottlerMetadata(descriptor.value, options); 55 | return descriptor; 56 | } 57 | setThrottlerMetadata(target, options); 58 | return target; 59 | }; 60 | }; 61 | 62 | /** 63 | * Adds metadata to the target which will be handled by the ThrottlerGuard 64 | * whether or not to skip throttling for this context. 65 | * @example @SkipThrottle() 66 | * @example @SkipThrottle(false) 67 | * @publicApi 68 | */ 69 | export const SkipThrottle = ( 70 | skip: Record = { default: true }, 71 | ): MethodDecorator & ClassDecorator => { 72 | return ( 73 | target: any, 74 | propertyKey?: string | symbol, 75 | descriptor?: TypedPropertyDescriptor, 76 | ) => { 77 | const reflectionTarget = descriptor?.value ?? target; 78 | for (const key in skip) { 79 | Reflect.defineMetadata(THROTTLER_SKIP + key, skip[key], reflectionTarget); 80 | } 81 | return descriptor ?? target; 82 | }; 83 | }; 84 | 85 | /** 86 | * Sets the proper injection token for the `THROTTLER_OPTIONS` 87 | * @example @InjectThrottlerOptions() 88 | * @publicApi 89 | */ 90 | export const InjectThrottlerOptions = () => Inject(getOptionsToken()); 91 | 92 | /** 93 | * Sets the proper injection token for the `ThrottlerStorage` 94 | * @example @InjectThrottlerStorage() 95 | * @publicApi 96 | */ 97 | export const InjectThrottlerStorage = () => Inject(getStorageToken()); 98 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: "If something isn't working as expected \U0001F914" 3 | labels: ["needs triage", "bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## :warning: We use GitHub Issues to track bug reports, feature requests and regressions 9 | 10 | If you are not sure that your issue is a bug, you could: 11 | 12 | - use our [Discord community](https://discord.gg/NestJS) 13 | - use [StackOverflow using the tag `nestjs`](https://stackoverflow.com/questions/tagged/nestjs) 14 | - If it's just a quick question you can ping [our Twitter](https://twitter.com/nestframework) 15 | 16 | **NOTE:** You don't need to answer questions that you know that aren't relevant. 17 | 18 | --- 19 | 20 | - type: checkboxes 21 | attributes: 22 | label: "Is there an existing issue for this?" 23 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the bug you encountered" 24 | options: 25 | - label: "I have searched the existing issues" 26 | required: true 27 | 28 | - type: textarea 29 | validations: 30 | required: true 31 | attributes: 32 | label: "Current behavior" 33 | description: "How the issue manifests?" 34 | 35 | - type: input 36 | validations: 37 | required: true 38 | attributes: 39 | label: "Minimum reproduction code" 40 | description: "An URL to some git repository or gist that reproduces this issue. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction)" 41 | placeholder: "https://github.com/..." 42 | 43 | - type: textarea 44 | attributes: 45 | label: "Steps to reproduce" 46 | description: | 47 | How the issue manifests? 48 | You could leave this blank if you alread write this in your reproduction code/repo 49 | placeholder: | 50 | 1. `npm i` 51 | 2. `npm start:dev` 52 | 3. See error... 53 | 54 | - type: textarea 55 | validations: 56 | required: true 57 | attributes: 58 | label: "Expected behavior" 59 | description: "A clear and concise description of what you expected to happend (or code)" 60 | 61 | - type: markdown 62 | attributes: 63 | value: | 64 | --- 65 | 66 | - type: input 67 | validations: 68 | required: true 69 | attributes: 70 | label: "Package version" 71 | description: | 72 | Which version of `@nestjs/throttler` are you using? 73 | **Tip**: Make sure that all of yours `@nestjs/*` dependencies are in sync! 74 | placeholder: "8.1.3" 75 | 76 | - type: input 77 | attributes: 78 | label: "NestJS version" 79 | description: "Which version of `@nestjs/core` are you using?" 80 | placeholder: "8.1.3" 81 | 82 | - type: input 83 | attributes: 84 | label: "Node.js version" 85 | description: "Which version of Node.js are you using?" 86 | placeholder: "14.17.6" 87 | 88 | - type: checkboxes 89 | attributes: 90 | label: "In which operating systems have you tested?" 91 | options: 92 | - label: macOS 93 | - label: Windows 94 | - label: Linux 95 | 96 | - type: markdown 97 | attributes: 98 | value: | 99 | --- 100 | 101 | - type: textarea 102 | attributes: 103 | label: "Other" 104 | description: | 105 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 106 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 107 | -------------------------------------------------------------------------------- /test/multi/multi-throttler.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { AbstractHttpAdapter } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test } from '@nestjs/testing'; 6 | import { setTimeout } from 'node:timers/promises'; 7 | import { request, spec } from 'pactum'; 8 | import { MultiThrottlerAppModule } from './app.module'; 9 | 10 | jest.setTimeout(10000); 11 | 12 | const commonHeader = (prefix: string, name?: string) => `${prefix}${name ? '-' + name : ''}`; 13 | 14 | const remainingHeader = (name?: string) => commonHeader('x-ratelimit-remaining', name); 15 | const limitHeader = (name?: string) => commonHeader('x-ratelimit-limit', name); 16 | const retryHeader = (name?: string) => commonHeader('retry-after', name); 17 | 18 | const short = 'short'; 19 | const long = 'long'; 20 | 21 | describe.each` 22 | adapter | name 23 | ${ExpressAdapter} | ${'express'} 24 | ${FastifyAdapter} | ${'fastify'} 25 | `('Multi-Throttler Named Usage - $name', ({ adapter }: { adapter: Type }) => { 26 | let app: INestApplication; 27 | beforeAll(async () => { 28 | const modRef = await Test.createTestingModule({ 29 | imports: [MultiThrottlerAppModule], 30 | }).compile(); 31 | app = modRef.createNestApplication(new adapter()); 32 | await app.listen(0); 33 | request.setBaseUrl(await app.getUrl()); 34 | }); 35 | afterAll(async () => { 36 | await app.close(); 37 | }); 38 | 39 | describe('Default Route: 1/s, 2/5s, 5/min', () => { 40 | it('should receive an exception when firing 2 requests within a second', async () => { 41 | await spec() 42 | .get('/') 43 | .expectStatus(200) 44 | .expectHeader(remainingHeader(short), '0') 45 | .expectHeader(limitHeader(short), '1'); 46 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(short), /^\d+$/); 47 | await setTimeout(1000); 48 | }); 49 | it('should get an error if we send two more requests within the first five seconds', async () => { 50 | await spec() 51 | .get('/') 52 | .expectStatus(200) 53 | .expectHeader(remainingHeader(), '0') 54 | .expectHeader(limitHeader(), '2'); 55 | await setTimeout(1000); 56 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(), /^\d+$/); 57 | await setTimeout(5000); 58 | }); 59 | it('should get an error if we smartly send 4 more requests within the minute', async () => { 60 | await spec() 61 | .get('/') 62 | .expectStatus(200) 63 | .expectHeader(limitHeader(long), '5') 64 | .expectHeader(remainingHeader(long), '2') 65 | .expectHeader(remainingHeader(short), '0'); 66 | await setTimeout(1000); 67 | await spec().get('/').expectStatus(200).expectHeader(remainingHeader(), '0'); 68 | console.log('waiting 5 seconds'); 69 | await setTimeout(5000); 70 | await spec() 71 | .get('/') 72 | .expectStatus(200) 73 | .expectHeader(remainingHeader(long), '0') 74 | .expectHeader(remainingHeader(short), '0') 75 | .expectHeader(remainingHeader(), '1'); 76 | await setTimeout(1000); 77 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(long), /^\d+$/); 78 | }); 79 | }); 80 | describe('skips', () => { 81 | it('should skip the short throttler', async () => { 82 | await spec().get('/skip-short').expectStatus(200).expectHeader(remainingHeader(), '1'); 83 | await spec().get('/skip-short').expectStatus(200).expectHeader(remainingHeader(), '0'); 84 | }); 85 | it('should skip the default and long trackers', async () => { 86 | await spec() 87 | .get('/skip-default-and-long') 88 | .expectStatus(200) 89 | .expectHeader(remainingHeader(short), '0') 90 | .expect((ctx) => { 91 | const { headers } = ctx.res; 92 | expect(headers[remainingHeader('default')]).toBeUndefined(); 93 | expect(headers[remainingHeader('long')]).toBeUndefined(); 94 | }); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/function-overrides/function-overrides-throttler.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { AbstractHttpAdapter } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test } from '@nestjs/testing'; 6 | import { setTimeout } from 'node:timers/promises'; 7 | import { request, spec } from 'pactum'; 8 | import { FunctionOverridesThrottlerModule } from './app.module'; 9 | 10 | jest.setTimeout(10000); 11 | 12 | const commonHeader = (prefix: string, name?: string) => `${prefix}${name ? '-' + name : ''}`; 13 | 14 | const remainingHeader = (name?: string) => commonHeader('x-ratelimit-remaining', name); 15 | const limitHeader = (name?: string) => commonHeader('x-ratelimit-limit', name); 16 | const retryHeader = (name?: string) => commonHeader('retry-after', name); 17 | 18 | const custom = 'custom'; 19 | 20 | describe.each` 21 | adapter | name 22 | ${ExpressAdapter} | ${'express'} 23 | ${FastifyAdapter} | ${'fastify'} 24 | `( 25 | 'Function-Overrides-Throttler Named Usage - $name', 26 | ({ adapter }: { adapter: Type }) => { 27 | let app: INestApplication; 28 | beforeAll(async () => { 29 | const modRef = await Test.createTestingModule({ 30 | imports: [FunctionOverridesThrottlerModule], 31 | }).compile(); 32 | app = modRef.createNestApplication(new adapter()); 33 | await app.listen(0); 34 | request.setBaseUrl(await app.getUrl()); 35 | }); 36 | afterAll(async () => { 37 | await app.close(); 38 | }); 39 | 40 | describe('Default Routes', () => { 41 | it('should receive an exception when firing 3 requests within 3 seconds to the same endpoint', async () => { 42 | await spec() 43 | .get('/') 44 | .expectStatus(200) 45 | .expectHeader(remainingHeader(), '1') 46 | .expectHeader(limitHeader(), '2'); 47 | await spec() 48 | .get('/1') 49 | .expectStatus(200) 50 | .expectHeader(remainingHeader(), '1') 51 | .expectHeader(limitHeader(), '2'); 52 | await spec().get('/').expectStatus(200).expectHeaderContains(remainingHeader(), '0'); 53 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(), /^\d+$/); 54 | await spec().get('/1').expectStatus(200).expectHeaderContains(remainingHeader(), '0'); 55 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(), /^\d+$/); 56 | await setTimeout(3000); 57 | await spec() 58 | .get('/') 59 | .expectStatus(200) 60 | .expectHeader(remainingHeader(), '1') 61 | .expectHeader(limitHeader(), '2'); 62 | }); 63 | }); 64 | 65 | describe('Custom Routes', () => { 66 | it('should receive an exception when firing 3 requests within 3 seconds to any endpoint', async () => { 67 | await spec() 68 | .get('/custom') 69 | .expectStatus(200) 70 | .expectHeader(remainingHeader(custom), '1') 71 | .expectHeader(limitHeader(custom), '2'); 72 | await spec() 73 | .get('/custom/1') 74 | .expectStatus(200) 75 | .expectHeader(remainingHeader(custom), '0') 76 | .expectHeader(limitHeader(custom), '2'); 77 | await spec() 78 | .get('/custom') 79 | .expectStatus(429) 80 | .expectHeaderContains(retryHeader(custom), /^\d+$/); 81 | await spec() 82 | .get('/custom/1') 83 | .expectStatus(429) 84 | .expectHeaderContains(retryHeader(custom), /^\d+$/); 85 | await setTimeout(3000); 86 | await spec() 87 | .get('/custom') 88 | .expectStatus(200) 89 | .expectHeader(remainingHeader(custom), '1') 90 | .expectHeader(limitHeader(custom), '2'); 91 | await spec() 92 | .get('/custom/1') 93 | .expectStatus(200) 94 | .expectHeader(remainingHeader(custom), '0') 95 | .expectHeader(limitHeader(custom), '2'); 96 | }); 97 | }); 98 | }, 99 | ); 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nestjs/throttler", 3 | "version": "6.5.0", 4 | "description": "A Rate-Limiting module for NestJS to work on Express, Fastify, Websockets, Socket.IO, and GraphQL, all rolled up into a simple package.", 5 | "author": "Jay McDoniel ", 6 | "contributors": [], 7 | "keywords": [ 8 | "nestjs", 9 | "rate-limit", 10 | "throttle", 11 | "express", 12 | "fastify", 13 | "ws", 14 | "gql", 15 | "nest" 16 | ], 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "private": false, 21 | "license": "MIT", 22 | "main": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "prebuild": "rimraf dist", 29 | "preversion": "yarn run format && yarn run lint && yarn build", 30 | "build": "nest build", 31 | "commit": "git-cz", 32 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 33 | "start:dev": "nodemon --watch '{src,test/app}/**/*.ts' --ignore '**/*.spec.ts' --exec 'ts-node' test/app/main.ts", 34 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 35 | "test": "jest", 36 | "test:watch": "jest --watch", 37 | "test:cov": "jest --coverage", 38 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 39 | "test:e2e": "jest --config ./test/jest-e2e.json --detectOpenHandles", 40 | "test:e2e:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --config test/jest-e2e.json --runInBand", 41 | "test:e2e:dev": "yarn test:e2e --watchAll", 42 | "prepare": "husky", 43 | "postpublish": "pinst --enable", 44 | "prepublishOnly": "pinst --disable", 45 | "release": "changeset publish" 46 | }, 47 | "devDependencies": { 48 | "@apollo/server": "5.2.0", 49 | "@changesets/cli": "2.29.8", 50 | "@commitlint/cli": "20.2.0", 51 | "@commitlint/config-angular": "20.2.0", 52 | "@nestjs/cli": "11.0.14", 53 | "@nestjs/common": "11.1.10", 54 | "@nestjs/core": "11.1.10", 55 | "@nestjs/graphql": "13.2.3", 56 | "@nestjs/platform-express": "11.1.10", 57 | "@nestjs/platform-fastify": "11.1.10", 58 | "@nestjs/platform-socket.io": "11.1.10", 59 | "@nestjs/platform-ws": "11.1.10", 60 | "@nestjs/schematics": "11.0.9", 61 | "@nestjs/testing": "11.1.10", 62 | "@nestjs/websockets": "11.1.10", 63 | "@semantic-release/git": "10.0.1", 64 | "@types/express": "5.0.6", 65 | "@types/express-serve-static-core": "5.1.0", 66 | "@types/jest": "29.5.14", 67 | "@types/node": "24.10.4", 68 | "@types/supertest": "6.0.3", 69 | "@typescript-eslint/eslint-plugin": "7.18.0", 70 | "@typescript-eslint/parser": "7.18.0", 71 | "apollo-server-fastify": "3.13.0", 72 | "conventional-changelog-cli": "5.0.0", 73 | "cz-conventional-changelog": "3.3.0", 74 | "eslint": "8.57.1", 75 | "eslint-config-prettier": "10.1.8", 76 | "eslint-plugin-import": "2.32.0", 77 | "graphql": "16.12.0", 78 | "graphql-tools": "9.0.25", 79 | "husky": "9.1.7", 80 | "jest": "29.7.0", 81 | "lint-staged": "16.2.7", 82 | "nodemon": "3.1.11", 83 | "pactum": "^3.4.1", 84 | "pinst": "3.0.0", 85 | "prettier": "3.7.4", 86 | "reflect-metadata": "0.2.2", 87 | "rimraf": "6.1.2", 88 | "rxjs": "7.8.2", 89 | "socket.io": "4.8.3", 90 | "supertest": "7.1.4", 91 | "ts-jest": "29.4.6", 92 | "ts-loader": "9.5.4", 93 | "ts-node": "10.9.2", 94 | "tsconfig-paths": "4.2.0", 95 | "typescript": "5.9.3", 96 | "ws": "8.18.3" 97 | }, 98 | "peerDependencies": { 99 | "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", 100 | "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", 101 | "reflect-metadata": "^0.1.13 || ^0.2.0" 102 | }, 103 | "jest": { 104 | "moduleFileExtensions": [ 105 | "js", 106 | "json", 107 | "ts" 108 | ], 109 | "rootDir": "src", 110 | "testRegex": ".spec.ts$", 111 | "transform": { 112 | "^.+\\.(t|j)s$": "ts-jest" 113 | }, 114 | "coverageDirectory": "../coverage", 115 | "testEnvironment": "node" 116 | }, 117 | "repository": { 118 | "type": "git", 119 | "url": "git+https://github.com/nestjs/throttler.git" 120 | }, 121 | "bugs": { 122 | "url": "https://github.com/nestjs/throttler/issues" 123 | }, 124 | "homepage": "https://github.com/nestjs/throttler#readme" 125 | } 126 | -------------------------------------------------------------------------------- /src/throttler-module-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, ModuleMetadata, Type } from '@nestjs/common/interfaces'; 2 | import { ThrottlerStorage } from './throttler-storage.interface'; 3 | import { ThrottlerLimitDetail } from './throttler.guard.interface'; 4 | 5 | export type Resolvable = 6 | | T 7 | | ((context: ExecutionContext) => T | Promise); 8 | 9 | /** 10 | * @publicApi 11 | */ 12 | export interface ThrottlerOptions { 13 | /** 14 | * The name for the rate limit to be used. 15 | * This can be left blank and it will be tracked as "default" internally. 16 | * If this is set, it will be added to the return headers. 17 | * e.g. x-ratelimit-remaining-long: 5 18 | */ 19 | name?: string; 20 | 21 | /** 22 | * The amount of requests that are allowed within the ttl's time window. 23 | */ 24 | limit: Resolvable; 25 | 26 | /** 27 | * The number of milliseconds the limit of requests are allowed 28 | */ 29 | ttl: Resolvable; 30 | 31 | /** 32 | * The number of milliseconds the request will be blocked. 33 | */ 34 | blockDuration?: Resolvable; 35 | 36 | /** 37 | * The user agents that should be ignored (checked against the User-Agent header). 38 | */ 39 | ignoreUserAgents?: RegExp[]; 40 | 41 | /** 42 | * A factory method to determine if throttling should be skipped. 43 | * This can be based on the incoming context, or something like an env value. 44 | */ 45 | skipIf?: (context: ExecutionContext) => boolean; 46 | /** 47 | * A method to override the default tracker string. 48 | */ 49 | getTracker?: ThrottlerGetTrackerFunction; 50 | /** 51 | * A method to override the default key generator. 52 | */ 53 | generateKey?: ThrottlerGenerateKeyFunction; 54 | 55 | /** 56 | * Weather to add the rate limit headers to the response. 57 | */ 58 | setHeaders?: boolean; 59 | } 60 | 61 | /** 62 | * @publicApi 63 | */ 64 | export type ThrottlerModuleOptions = 65 | | Array 66 | | { 67 | /** 68 | * The user agents that should be ignored (checked against the User-Agent header). 69 | */ 70 | ignoreUserAgents?: RegExp[]; 71 | /** 72 | * A factory method to determine if throttling should be skipped. 73 | * This can be based on the incoming context, or something like an env value. 74 | */ 75 | skipIf?: (context: ExecutionContext) => boolean; 76 | /** 77 | * A method to override the default tracker string. 78 | */ 79 | getTracker?: ThrottlerGetTrackerFunction; 80 | /** 81 | * A method to override the default key generator. 82 | */ 83 | generateKey?: ThrottlerGenerateKeyFunction; 84 | /** 85 | * An optional message to override the default error message. 86 | */ 87 | errorMessage?: 88 | | string 89 | | ((context: ExecutionContext, throttlerLimitDetail: ThrottlerLimitDetail) => string); 90 | 91 | /** 92 | * The storage class to use where all the record will be stored in. 93 | */ 94 | storage?: ThrottlerStorage; 95 | /** 96 | * The named throttlers to use 97 | */ 98 | throttlers: Array; 99 | 100 | /** 101 | * Weather to add the rate limit headers to the response. 102 | */ 103 | setHeaders?: boolean; 104 | }; 105 | 106 | /** 107 | * @publicApi 108 | */ 109 | export interface ThrottlerOptionsFactory { 110 | createThrottlerOptions(): Promise | ThrottlerModuleOptions; 111 | } 112 | 113 | /** 114 | * @publicApi 115 | */ 116 | export interface ThrottlerAsyncOptions extends Pick { 117 | /** 118 | * The `useExisting` syntax allows you to create aliases for existing providers. 119 | */ 120 | useExisting?: Type; 121 | /** 122 | * The `useClass` syntax allows you to dynamically determine a class 123 | * that a token should resolve to. 124 | */ 125 | useClass?: Type; 126 | /** 127 | * The `useFactory` syntax allows for creating providers dynamically. 128 | */ 129 | useFactory?: (...args: any[]) => Promise | ThrottlerModuleOptions; 130 | /** 131 | * Optional list of providers to be injected into the context of the Factory function. 132 | */ 133 | inject?: any[]; 134 | } 135 | 136 | /** 137 | * @publicApi 138 | */ 139 | export type ThrottlerGetTrackerFunction = ( 140 | req: Record, 141 | context: ExecutionContext, 142 | ) => Promise | string; 143 | 144 | /** 145 | * @publicApi 146 | */ 147 | export type ThrottlerGenerateKeyFunction = ( 148 | context: ExecutionContext, 149 | trackerString: string, 150 | throttlerName: string, 151 | ) => string; 152 | -------------------------------------------------------------------------------- /src/throttler.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnApplicationShutdown } from '@nestjs/common'; 2 | import { ThrottlerStorageOptions } from './throttler-storage-options.interface'; 3 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 4 | import { ThrottlerStorage } from './throttler-storage.interface'; 5 | 6 | /** 7 | * @publicApi 8 | */ 9 | @Injectable() 10 | export class ThrottlerStorageService implements ThrottlerStorage, OnApplicationShutdown { 11 | private _storage: Map = new Map(); 12 | private timeoutIds: Map = new Map(); 13 | 14 | get storage(): Map { 15 | return this._storage; 16 | } 17 | 18 | /** 19 | * Get the expiration time in seconds from a single record. 20 | */ 21 | private getExpirationTime(key: string): number { 22 | return Math.ceil((this.storage.get(key).expiresAt - Date.now()) / 1000); 23 | } 24 | 25 | /** 26 | * Get the block expiration time in seconds from a single record. 27 | */ 28 | private getBlockExpirationTime(key: string): number { 29 | return Math.ceil((this.storage.get(key).blockExpiresAt - Date.now()) / 1000); 30 | } 31 | 32 | /** 33 | * Set the expiration time for a given key. 34 | */ 35 | private setExpirationTime(key: string, ttlMilliseconds: number, throttlerName: string): void { 36 | const timeoutId = setTimeout(() => { 37 | const { totalHits } = this.storage.get(key); 38 | totalHits.set(throttlerName, totalHits.get(throttlerName) - 1); 39 | clearTimeout(timeoutId); 40 | this.timeoutIds.set( 41 | throttlerName, 42 | this.timeoutIds.get(throttlerName).filter((id) => id !== timeoutId), 43 | ); 44 | }, ttlMilliseconds); 45 | this.timeoutIds.get(throttlerName).push(timeoutId); 46 | } 47 | 48 | /** 49 | * Clear the expiration time related to the throttle 50 | */ 51 | private clearExpirationTimes(throttlerName: string) { 52 | this.timeoutIds.get(throttlerName).forEach(clearTimeout); 53 | this.timeoutIds.set(throttlerName, []); 54 | } 55 | 56 | /** 57 | * Reset the request blockage 58 | */ 59 | private resetBlockdRequest(key: string, throttlerName: string) { 60 | this.storage.get(key).isBlocked = false; 61 | this.storage.get(key).totalHits.set(throttlerName, 0); 62 | this.clearExpirationTimes(throttlerName); 63 | } 64 | 65 | /** 66 | * Increase the `totalHit` count and sent it to decrease queue 67 | */ 68 | private fireHitCount(key: string, throttlerName: string, ttl: number) { 69 | const { totalHits } = this.storage.get(key); 70 | totalHits.set(throttlerName, totalHits.get(throttlerName) + 1); 71 | this.setExpirationTime(key, ttl, throttlerName); 72 | } 73 | 74 | async increment( 75 | key: string, 76 | ttl: number, 77 | limit: number, 78 | blockDuration: number, 79 | throttlerName: string, 80 | ): Promise { 81 | const ttlMilliseconds = ttl; 82 | const blockDurationMilliseconds = blockDuration; 83 | 84 | if (!this.timeoutIds.has(throttlerName)) { 85 | this.timeoutIds.set(throttlerName, []); 86 | } 87 | 88 | if (!this.storage.has(key)) { 89 | this.storage.set(key, { 90 | totalHits: new Map([[throttlerName, 0]]), 91 | expiresAt: Date.now() + ttlMilliseconds, 92 | blockExpiresAt: 0, 93 | isBlocked: false, 94 | }); 95 | } 96 | 97 | let timeToExpire = this.getExpirationTime(key); 98 | 99 | // Reset the timeToExpire once it has been expired. 100 | if (timeToExpire <= 0) { 101 | this.storage.get(key).expiresAt = Date.now() + ttlMilliseconds; 102 | timeToExpire = this.getExpirationTime(key); 103 | } 104 | 105 | if (!this.storage.get(key).isBlocked) { 106 | this.fireHitCount(key, throttlerName, ttlMilliseconds); 107 | } 108 | 109 | // Reset the blockExpiresAt once it gets blocked 110 | if ( 111 | this.storage.get(key).totalHits.get(throttlerName) > limit && 112 | !this.storage.get(key).isBlocked 113 | ) { 114 | this.storage.get(key).isBlocked = true; 115 | this.storage.get(key).blockExpiresAt = Date.now() + blockDurationMilliseconds; 116 | } 117 | 118 | const timeToBlockExpire = this.getBlockExpirationTime(key); 119 | 120 | // Reset time blocked request 121 | if (timeToBlockExpire <= 0 && this.storage.get(key).isBlocked) { 122 | this.resetBlockdRequest(key, throttlerName); 123 | this.fireHitCount(key, throttlerName, ttlMilliseconds); 124 | } 125 | 126 | return { 127 | totalHits: this.storage.get(key).totalHits.get(throttlerName), 128 | timeToExpire, 129 | isBlocked: this.storage.get(key).isBlocked, 130 | timeToBlockExpire: timeToBlockExpire, 131 | }; 132 | } 133 | 134 | onApplicationShutdown() { 135 | this.timeoutIds.forEach((timeouts) => timeouts.forEach(clearTimeout)); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [me@jaymcdoniel.dev](mailto:me@jaymcdoniel.dev). All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | 79 | For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 80 | -------------------------------------------------------------------------------- /test/controller.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Post } from '@nestjs/common'; 2 | import { AbstractHttpAdapter, APP_GUARD } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | import { setTimeout } from 'node:timers/promises'; 7 | import { Throttle, ThrottlerGuard } from '../src'; 8 | import { THROTTLER_OPTIONS } from '../src/throttler.constants'; 9 | import { ControllerModule } from './app/controllers/controller.module'; 10 | import { httPromise } from './utility/httpromise'; 11 | 12 | jest.setTimeout(45000); 13 | 14 | describe.each` 15 | adapter | adapterName 16 | ${new ExpressAdapter()} | ${'Express'} 17 | ${new FastifyAdapter()} | ${'Fastify'} 18 | `( 19 | '$adapterName Throttler', 20 | ({ adapter }: { adapter: AbstractHttpAdapter; adapterName: string }) => { 21 | @Controller('test/throttle') 22 | class ThrottleTestController { 23 | @Throttle({ default: { limit: 1, ttl: 1000, blockDuration: 1000 } }) 24 | @Post() 25 | async testThrottle() { 26 | return { 27 | code: 'THROTTLE_TEST', 28 | }; 29 | } 30 | } 31 | let app: INestApplication; 32 | 33 | beforeAll(async () => { 34 | const moduleFixture: TestingModule = await Test.createTestingModule({ 35 | imports: [ControllerModule], 36 | controllers: [ThrottleTestController], 37 | providers: [ 38 | { 39 | provide: APP_GUARD, 40 | useClass: ThrottlerGuard, 41 | }, 42 | ], 43 | }).compile(); 44 | 45 | app = moduleFixture.createNestApplication(adapter); 46 | await app.listen(0); 47 | }); 48 | 49 | afterAll(async () => { 50 | await app.close(); 51 | }); 52 | 53 | describe('controllers', () => { 54 | let appUrl: string; 55 | beforeAll(async () => { 56 | appUrl = await app.getUrl(); 57 | }); 58 | 59 | /** 60 | * Tests for setting `@Throttle()` at the method level and for ignore routes 61 | */ 62 | describe('AppController', () => { 63 | it('GET /ignored', async () => { 64 | const response = await httPromise(appUrl + '/ignored'); 65 | expect(response.data).toEqual({ ignored: true }); 66 | expect(response.headers).not.toMatchObject({ 67 | 'x-ratelimit-limit': '2', 68 | 'x-ratelimit-remaining': '1', 69 | 'x-ratelimit-reset': /^\d+$/, 70 | }); 71 | }); 72 | it('GET /ignore-user-agents', async () => { 73 | const response = await httPromise(appUrl + '/ignore-user-agents', 'GET', { 74 | 'user-agent': 'throttler-test/0.0.0', 75 | }); 76 | expect(response.data).toEqual({ ignored: true }); 77 | expect(response.headers).not.toMatchObject({ 78 | 'x-ratelimit-limit': '2', 79 | 'x-ratelimit-remaining': '1', 80 | 'x-ratelimit-reset': /^\d+$/, 81 | }); 82 | }); 83 | it('GET /', async () => { 84 | const response = await httPromise(appUrl + '/'); 85 | expect(response.data).toEqual({ success: true }); 86 | expect(response.headers).toMatchObject({ 87 | 'x-ratelimit-limit': '2', 88 | 'x-ratelimit-remaining': '1', 89 | 'x-ratelimit-reset': /^\d+$/, 90 | }); 91 | }); 92 | }); 93 | /** 94 | * Tests for setting `@Throttle()` at the class level and overriding at the method level 95 | */ 96 | describe('LimitController', () => { 97 | it.each` 98 | method | url | limit | blockDuration 99 | ${'GET'} | ${''} | ${2} | ${5000} 100 | ${'GET'} | ${'/higher'} | ${5} | ${15000} 101 | `( 102 | '$method $url', 103 | async ({ 104 | method, 105 | url, 106 | limit, 107 | blockDuration, 108 | }: { 109 | method: 'GET'; 110 | url: string; 111 | limit: number; 112 | blockDuration: number; 113 | }) => { 114 | for (let i = 0; i < limit; i++) { 115 | const response = await httPromise(appUrl + '/limit' + url, method); 116 | expect(response.data).toEqual({ success: true }); 117 | expect(response.headers).toMatchObject({ 118 | 'x-ratelimit-limit': limit.toString(), 119 | 'x-ratelimit-remaining': (limit - (i + 1)).toString(), 120 | 'x-ratelimit-reset': /^\d+$/, 121 | }); 122 | } 123 | const errRes = await httPromise(appUrl + '/limit' + url, method); 124 | expect(errRes.data).toMatchObject({ statusCode: 429, message: /ThrottlerException/ }); 125 | expect(errRes.headers).toMatchObject({ 126 | 'retry-after': /^\d+$/, 127 | }); 128 | expect(errRes.status).toBe(429); 129 | await setTimeout(blockDuration); 130 | const response = await httPromise(appUrl + '/limit' + url, method); 131 | expect(response.data).toEqual({ success: true }); 132 | expect(response.headers).toMatchObject({ 133 | 'x-ratelimit-limit': limit.toString(), 134 | 'x-ratelimit-remaining': (limit - 1).toString(), 135 | 'x-ratelimit-reset': /^\d+$/, 136 | }); 137 | }, 138 | ); 139 | }); 140 | /** 141 | * Tests for setting throttle values at the `forRoot` level 142 | */ 143 | describe('DefaultController', () => { 144 | it('GET /default', async () => { 145 | const limit = 5; 146 | const blockDuration = 20000; // 20 second 147 | for (let i = 0; i < limit; i++) { 148 | const response = await httPromise(appUrl + '/default'); 149 | expect(response.data).toEqual({ success: true }); 150 | expect(response.headers).toMatchObject({ 151 | 'x-ratelimit-limit': limit.toString(), 152 | 'x-ratelimit-remaining': (limit - (i + 1)).toString(), 153 | 'x-ratelimit-reset': /^\d+$/, 154 | }); 155 | } 156 | const errRes = await httPromise(appUrl + '/default'); 157 | expect(errRes.data).toMatchObject({ statusCode: 429, message: /ThrottlerException/ }); 158 | expect(errRes.headers).toMatchObject({ 159 | 'retry-after': /^\d+$/, 160 | }); 161 | expect(errRes.status).toBe(429); 162 | await setTimeout(blockDuration); 163 | const response = await httPromise(appUrl + '/default'); 164 | expect(response.data).toEqual({ success: true }); 165 | expect(response.headers).toMatchObject({ 166 | 'x-ratelimit-limit': limit.toString(), 167 | 'x-ratelimit-remaining': (limit - 1).toString(), 168 | 'x-ratelimit-reset': /^\d+$/, 169 | }); 170 | }); 171 | }); 172 | 173 | describe('ThrottlerTestController', () => { 174 | it('GET /test/throttle', async () => { 175 | const makeRequest = async () => httPromise(appUrl + '/test/throttle', 'POST', {}, {}); 176 | const res1 = await makeRequest(); 177 | expect(res1.status).toBe(201); 178 | await setTimeout(1000); 179 | const res2 = await makeRequest(); 180 | expect(res2.status).toBe(201); 181 | const res3 = await makeRequest(); 182 | expect(res3.status).toBe(429); 183 | const res4 = await makeRequest(); 184 | expect(res4.status).toBe(429); 185 | }); 186 | }); 187 | }); 188 | }, 189 | ); 190 | describe('SkipIf suite', () => { 191 | it('should skip throttling if skipIf returns true', async () => { 192 | const moduleFixture: TestingModule = await Test.createTestingModule({ 193 | imports: [ControllerModule], 194 | providers: [ 195 | { 196 | provide: APP_GUARD, 197 | useClass: ThrottlerGuard, 198 | }, 199 | ], 200 | }) 201 | .overrideProvider(THROTTLER_OPTIONS) 202 | .useValue([ 203 | { 204 | skipIf: () => true, 205 | limit: 5, 206 | }, 207 | ]) 208 | .compile(); 209 | 210 | const app = moduleFixture.createNestApplication(); 211 | await app.listen(0); 212 | const appUrl = await app.getUrl(); 213 | for (let i = 0; i < 15; i++) { 214 | const response = await httPromise(appUrl + '/'); 215 | expect(response.status).toBe(200); 216 | expect(response.data).toEqual({ success: true }); 217 | expect(response.headers).not.toMatchObject({ 218 | 'x-ratelimit-limit': '5', 219 | 'x-ratelimit-remaining': '4', 220 | 'x-ratelimit-reset': /^\d+$/, 221 | }); 222 | } 223 | await app.close(); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /src/throttler.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { sha256 } from './hash'; 4 | import { 5 | Resolvable, 6 | ThrottlerGenerateKeyFunction, 7 | ThrottlerGetTrackerFunction, 8 | ThrottlerModuleOptions, 9 | ThrottlerOptions, 10 | } from './throttler-module-options.interface'; 11 | import { ThrottlerStorage } from './throttler-storage.interface'; 12 | import { 13 | THROTTLER_BLOCK_DURATION, 14 | THROTTLER_KEY_GENERATOR, 15 | THROTTLER_LIMIT, 16 | THROTTLER_SKIP, 17 | THROTTLER_TRACKER, 18 | THROTTLER_TTL, 19 | } from './throttler.constants'; 20 | import { InjectThrottlerOptions, InjectThrottlerStorage } from './throttler.decorator'; 21 | import { ThrottlerException, throttlerMessage } from './throttler.exception'; 22 | import { ThrottlerLimitDetail, ThrottlerRequest } from './throttler.guard.interface'; 23 | 24 | /** 25 | * @publicApi 26 | */ 27 | @Injectable() 28 | export class ThrottlerGuard implements CanActivate { 29 | protected headerPrefix = 'X-RateLimit'; 30 | protected errorMessage = throttlerMessage; 31 | protected throttlers: Array; 32 | protected commonOptions: Pick< 33 | ThrottlerOptions, 34 | 'skipIf' | 'ignoreUserAgents' | 'getTracker' | 'generateKey' | 'setHeaders' 35 | >; 36 | 37 | constructor( 38 | @InjectThrottlerOptions() protected readonly options: ThrottlerModuleOptions, 39 | @InjectThrottlerStorage() protected readonly storageService: ThrottlerStorage, 40 | protected readonly reflector: Reflector, 41 | ) {} 42 | 43 | async onModuleInit() { 44 | this.throttlers = (Array.isArray(this.options) ? this.options : this.options.throttlers) 45 | .sort((first, second) => { 46 | if (typeof first.ttl === 'function') { 47 | return 1; 48 | } 49 | if (typeof second.ttl === 'function') { 50 | return 0; 51 | } 52 | return first.ttl - second.ttl; 53 | }) 54 | .map((opt) => ({ ...opt, name: opt.name ?? 'default' })); 55 | if (Array.isArray(this.options)) { 56 | this.commonOptions = {}; 57 | } else { 58 | this.commonOptions = { 59 | skipIf: this.options.skipIf, 60 | ignoreUserAgents: this.options.ignoreUserAgents, 61 | getTracker: this.options.getTracker, 62 | generateKey: this.options.generateKey, 63 | setHeaders: this.options.setHeaders, 64 | }; 65 | } 66 | this.commonOptions.getTracker ??= this.getTracker.bind(this); 67 | this.commonOptions.generateKey ??= this.generateKey.bind(this); 68 | } 69 | 70 | /** 71 | * Throttle requests against their TTL limit and whether to allow or deny it. 72 | * Based on the context type different handlers will be called. 73 | * @throws {ThrottlerException} 74 | */ 75 | async canActivate(context: ExecutionContext): Promise { 76 | const handler = context.getHandler(); 77 | const classRef = context.getClass(); 78 | 79 | if (await this.shouldSkip(context)) { 80 | return true; 81 | } 82 | const continues: boolean[] = []; 83 | 84 | for (const namedThrottler of this.throttlers) { 85 | // Return early if the current route should be skipped. 86 | const skip = this.reflector.getAllAndOverride(THROTTLER_SKIP + namedThrottler.name, [ 87 | handler, 88 | classRef, 89 | ]); 90 | const skipIf = namedThrottler.skipIf || this.commonOptions.skipIf; 91 | if (skip || skipIf?.(context)) { 92 | continues.push(true); 93 | continue; 94 | } 95 | 96 | // Return early when we have no limit or ttl data. 97 | const routeOrClassLimit = this.reflector.getAllAndOverride>( 98 | THROTTLER_LIMIT + namedThrottler.name, 99 | [handler, classRef], 100 | ); 101 | const routeOrClassTtl = this.reflector.getAllAndOverride>( 102 | THROTTLER_TTL + namedThrottler.name, 103 | [handler, classRef], 104 | ); 105 | const routeOrClassBlockDuration = this.reflector.getAllAndOverride>( 106 | THROTTLER_BLOCK_DURATION + namedThrottler.name, 107 | [handler, classRef], 108 | ); 109 | const routeOrClassGetTracker = this.reflector.getAllAndOverride( 110 | THROTTLER_TRACKER + namedThrottler.name, 111 | [handler, classRef], 112 | ); 113 | const routeOrClassGetKeyGenerator = 114 | this.reflector.getAllAndOverride( 115 | THROTTLER_KEY_GENERATOR + namedThrottler.name, 116 | [handler, classRef], 117 | ); 118 | 119 | // Check if specific limits are set at class or route level, otherwise use global options. 120 | const limit = await this.resolveValue(context, routeOrClassLimit || namedThrottler.limit); 121 | const ttl = await this.resolveValue(context, routeOrClassTtl || namedThrottler.ttl); 122 | const blockDuration = await this.resolveValue( 123 | context, 124 | routeOrClassBlockDuration || namedThrottler.blockDuration || ttl, 125 | ); 126 | const getTracker = 127 | routeOrClassGetTracker || namedThrottler.getTracker || this.commonOptions.getTracker; 128 | const generateKey = 129 | routeOrClassGetKeyGenerator || namedThrottler.generateKey || this.commonOptions.generateKey; 130 | 131 | continues.push( 132 | await this.handleRequest({ 133 | context, 134 | limit, 135 | ttl, 136 | throttler: namedThrottler, 137 | blockDuration, 138 | getTracker, 139 | generateKey, 140 | }), 141 | ); 142 | } 143 | return continues.every((cont) => cont); 144 | } 145 | 146 | protected async shouldSkip(_context: ExecutionContext): Promise { 147 | return false; 148 | } 149 | 150 | /** 151 | * Throttles incoming HTTP requests. 152 | * All the outgoing requests will contain RFC-compatible RateLimit headers. 153 | * @see https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html#header-specifications 154 | * @throws {ThrottlerException} 155 | */ 156 | protected async handleRequest(requestProps: ThrottlerRequest): Promise { 157 | const { context, limit, ttl, throttler, blockDuration, getTracker, generateKey } = requestProps; 158 | 159 | // Here we start to check the amount of requests being done against the ttl. 160 | const { req, res } = this.getRequestResponse(context); 161 | const ignoreUserAgents = throttler.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents; 162 | // Return early if the current user agent should be ignored. 163 | if (Array.isArray(ignoreUserAgents)) { 164 | for (const pattern of ignoreUserAgents) { 165 | if (pattern.test(req.headers['user-agent'])) { 166 | return true; 167 | } 168 | } 169 | } 170 | const tracker = await getTracker(req, context); 171 | const key = generateKey(context, tracker, throttler.name); 172 | const { totalHits, timeToExpire, isBlocked, timeToBlockExpire } = 173 | await this.storageService.increment(key, ttl, limit, blockDuration, throttler.name); 174 | 175 | const getThrottlerSuffix = (name: string) => (name === 'default' ? '' : `-${name}`); 176 | const setHeaders = throttler.setHeaders ?? this.commonOptions.setHeaders ?? true; 177 | 178 | // Throw an error when the user reached their limit. 179 | if (isBlocked) { 180 | if (setHeaders) { 181 | res.header(`Retry-After${getThrottlerSuffix(throttler.name)}`, timeToBlockExpire); 182 | } 183 | 184 | await this.throwThrottlingException(context, { 185 | limit, 186 | ttl, 187 | key, 188 | tracker, 189 | totalHits, 190 | timeToExpire, 191 | isBlocked, 192 | timeToBlockExpire, 193 | }); 194 | } 195 | 196 | if (setHeaders) { 197 | res.header(`${this.headerPrefix}-Limit${getThrottlerSuffix(throttler.name)}`, limit); 198 | // We're about to add a record so we need to take that into account here. 199 | // Otherwise the header says we have a request left when there are none. 200 | res.header( 201 | `${this.headerPrefix}-Remaining${getThrottlerSuffix(throttler.name)}`, 202 | Math.max(0, limit - totalHits), 203 | ); 204 | res.header(`${this.headerPrefix}-Reset${getThrottlerSuffix(throttler.name)}`, timeToExpire); 205 | } 206 | 207 | return true; 208 | } 209 | 210 | protected async getTracker(req: Record): Promise { 211 | return req.ip; 212 | } 213 | 214 | protected getRequestResponse(context: ExecutionContext): { 215 | req: Record; 216 | res: Record; 217 | } { 218 | const http = context.switchToHttp(); 219 | return { req: http.getRequest(), res: http.getResponse() }; 220 | } 221 | 222 | /** 223 | * Generate a hashed key that will be used as a storage key. 224 | * The key will always be a combination of the current context and IP. 225 | */ 226 | protected generateKey(context: ExecutionContext, suffix: string, name: string): string { 227 | const prefix = `${context.getClass().name}-${context.getHandler().name}-${name}`; 228 | return sha256(`${prefix}-${suffix}`); 229 | } 230 | 231 | /** 232 | * Throws an exception for the event that the rate limit has been exceeded. 233 | * 234 | * The context parameter allows to access the context when overwriting 235 | * the method. 236 | * @throws {ThrottlerException} 237 | */ 238 | protected async throwThrottlingException( 239 | context: ExecutionContext, 240 | throttlerLimitDetail: ThrottlerLimitDetail, 241 | ): Promise { 242 | throw new ThrottlerException(await this.getErrorMessage(context, throttlerLimitDetail)); 243 | } 244 | 245 | protected async getErrorMessage( 246 | context: ExecutionContext, 247 | throttlerLimitDetail: ThrottlerLimitDetail, 248 | ): Promise { 249 | if (!Array.isArray(this.options)) { 250 | if (!this.options.errorMessage) return this.errorMessage; 251 | 252 | return typeof this.options.errorMessage === 'function' 253 | ? this.options.errorMessage(context, throttlerLimitDetail) 254 | : this.options.errorMessage; 255 | } 256 | return this.errorMessage; 257 | } 258 | 259 | private async resolveValue( 260 | context: ExecutionContext, 261 | resolvableValue: Resolvable, 262 | ): Promise { 263 | return typeof resolvableValue === 'function' ? resolvableValue(context) : resolvableValue; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/throttler.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { Test } from '@nestjs/testing'; 4 | import { ThrottlerStorageOptions } from './throttler-storage-options.interface'; 5 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 6 | import { ThrottlerStorage } from './throttler-storage.interface'; 7 | import { THROTTLER_OPTIONS } from './throttler.constants'; 8 | import { ThrottlerException } from './throttler.exception'; 9 | import { ThrottlerGuard } from './throttler.guard'; 10 | 11 | class ThrottlerStorageServiceMock implements ThrottlerStorage { 12 | private _storage: Map = new Map(); 13 | get storage(): Map { 14 | return this._storage; 15 | } 16 | 17 | private getExpirationTime(key: string): number { 18 | return Math.floor((this.storage[key].expiresAt - Date.now()) / 1000); 19 | } 20 | 21 | private getBlockExpirationTime(key: string): number { 22 | return Math.floor((this.storage[key].blockExpiresAt - Date.now()) / 1000); 23 | } 24 | 25 | private fireHitCount(key: string, throttlerName: string) { 26 | this.storage[key].totalHits[throttlerName]++; 27 | } 28 | 29 | async increment( 30 | key: string, 31 | ttl: number, 32 | limit: number, 33 | blockDuration: number, 34 | throttlerName: string, 35 | ): Promise { 36 | const ttlMilliseconds = ttl; 37 | const blockDurationMilliseconds = blockDuration; 38 | if (!this.storage[key]) { 39 | this.storage[key] = { 40 | totalHits: { 41 | [throttlerName]: 0, 42 | }, 43 | expiresAt: Date.now() + ttlMilliseconds, 44 | blockExpiresAt: 0, 45 | isBlocked: false, 46 | }; 47 | } 48 | 49 | let timeToExpire = this.getExpirationTime(key); 50 | 51 | // Reset the `expiresAt` once it has been expired. 52 | if (timeToExpire <= 0) { 53 | this.storage[key].expiresAt = Date.now() + ttlMilliseconds; 54 | timeToExpire = this.getExpirationTime(key); 55 | } 56 | 57 | if (!this.storage[key].isBlocked) { 58 | this.fireHitCount(key, throttlerName); 59 | } 60 | 61 | // Reset the blockExpiresAt once it gets blocked 62 | if (this.storage[key].totalHits[throttlerName] > limit && !this.storage[key].isBlocked) { 63 | this.storage[key].isBlocked = true; 64 | this.storage[key].blockExpiresAt = Date.now() + blockDurationMilliseconds; 65 | } 66 | 67 | const timeToBlockExpire = this.getBlockExpirationTime(key); 68 | 69 | if (timeToBlockExpire <= 0 && this.storage[key].isBlocked) { 70 | this.fireHitCount(key, throttlerName); 71 | } 72 | 73 | return { 74 | totalHits: this.storage[key].totalHits[throttlerName], 75 | timeToExpire, 76 | isBlocked: this.storage[key].isBlocked, 77 | timeToBlockExpire: timeToBlockExpire, 78 | }; 79 | } 80 | } 81 | 82 | function contextMockFactory( 83 | type: 'http' | 'ws' | 'graphql', 84 | handler: () => any, 85 | mockFunc: Record, 86 | ): ExecutionContext { 87 | const executionPartial: Partial = { 88 | getClass: () => ThrottlerStorageServiceMock as any, 89 | getHandler: () => handler, 90 | switchToRpc: () => ({ 91 | getContext: () => ({}) as any, 92 | getData: () => ({}) as any, 93 | }), 94 | getArgs: () => [] as any, 95 | getArgByIndex: () => ({}) as any, 96 | getType: () => type as any, 97 | }; 98 | switch (type) { 99 | case 'ws': 100 | executionPartial.switchToHttp = () => ({}) as any; 101 | executionPartial.switchToWs = () => mockFunc as any; 102 | break; 103 | case 'http': 104 | executionPartial.switchToWs = () => ({}) as any; 105 | executionPartial.switchToHttp = () => mockFunc as any; 106 | break; 107 | case 'graphql': 108 | executionPartial.switchToWs = () => ({}) as any; 109 | executionPartial.switchToHttp = () => 110 | ({ 111 | getNext: () => ({}) as any, 112 | }) as any; 113 | executionPartial.getArgByIndex = () => mockFunc as any; 114 | break; 115 | } 116 | return executionPartial as ExecutionContext; 117 | } 118 | 119 | describe('ThrottlerGuard', () => { 120 | let guard: ThrottlerGuard; 121 | let reflector: Reflector; 122 | let service: ThrottlerStorageServiceMock; 123 | let handler: () => any; 124 | 125 | beforeEach(async () => { 126 | const modRef = await Test.createTestingModule({ 127 | providers: [ 128 | ThrottlerGuard, 129 | { 130 | provide: THROTTLER_OPTIONS, 131 | useValue: [ 132 | { 133 | limit: 5, 134 | ttl: 60, 135 | ignoreUserAgents: [/userAgentIgnore/], 136 | }, 137 | ], 138 | }, 139 | { 140 | provide: ThrottlerStorage, 141 | useClass: ThrottlerStorageServiceMock, 142 | }, 143 | { 144 | provide: Reflector, 145 | useValue: { 146 | getAllAndOverride: jest.fn(), 147 | }, 148 | }, 149 | ], 150 | }).compile(); 151 | guard = modRef.get(ThrottlerGuard); 152 | await guard.onModuleInit(); 153 | reflector = modRef.get(Reflector); 154 | service = modRef.get(ThrottlerStorage); 155 | }); 156 | 157 | it('should have all of the providers defined', () => { 158 | expect(guard).toBeDefined(); 159 | expect(reflector).toBeDefined(); 160 | expect(service).toBeDefined(); 161 | }); 162 | describe('HTTP Context', () => { 163 | let reqMock; 164 | let resMock; 165 | let headerSettingMock: jest.Mock; 166 | 167 | beforeEach(() => { 168 | headerSettingMock = jest.fn(); 169 | resMock = { 170 | header: headerSettingMock, 171 | }; 172 | reqMock = { 173 | headers: {}, 174 | }; 175 | }); 176 | afterEach(() => { 177 | headerSettingMock.mockClear(); 178 | }); 179 | it('should add headers to the res', async () => { 180 | handler = function addHeaders() { 181 | return 'string'; 182 | }; 183 | const ctxMock = contextMockFactory('http', handler, { 184 | getResponse: () => resMock, 185 | getRequest: () => reqMock, 186 | }); 187 | const canActivate = await guard.canActivate(ctxMock); 188 | expect(canActivate).toBe(true); 189 | expect(headerSettingMock).toBeCalledTimes(3); 190 | expect(headerSettingMock).toHaveBeenNthCalledWith(1, 'X-RateLimit-Limit', 5); 191 | expect(headerSettingMock).toHaveBeenNthCalledWith(2, 'X-RateLimit-Remaining', 4); 192 | expect(headerSettingMock).toHaveBeenNthCalledWith(3, 'X-RateLimit-Reset', expect.any(Number)); 193 | }); 194 | it('should return an error after passing the limit', async () => { 195 | handler = function returnError() { 196 | return 'string'; 197 | }; 198 | const ctxMock = contextMockFactory('http', handler, { 199 | getResponse: () => resMock, 200 | getRequest: () => reqMock, 201 | }); 202 | for (let i = 0; i < 5; i++) { 203 | await guard.canActivate(ctxMock); 204 | } 205 | await expect(guard.canActivate(ctxMock)).rejects.toThrowError(ThrottlerException); 206 | expect(headerSettingMock).toBeCalledTimes(16); 207 | expect(headerSettingMock).toHaveBeenLastCalledWith('Retry-After', expect.any(Number)); 208 | }); 209 | it('should pull values from the reflector instead of options', async () => { 210 | handler = function useReflector() { 211 | return 'string'; 212 | }; 213 | reflector.getAllAndOverride = jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(2); 214 | const ctxMock = contextMockFactory('http', handler, { 215 | getResponse: () => resMock, 216 | getRequest: () => reqMock, 217 | }); 218 | const canActivate = await guard.canActivate(ctxMock); 219 | expect(canActivate).toBe(true); 220 | expect(headerSettingMock).toBeCalledTimes(3); 221 | expect(headerSettingMock).toHaveBeenNthCalledWith(1, 'X-RateLimit-Limit', 2); 222 | expect(headerSettingMock).toHaveBeenNthCalledWith(2, 'X-RateLimit-Remaining', 1); 223 | expect(headerSettingMock).toHaveBeenNthCalledWith(3, 'X-RateLimit-Reset', expect.any(Number)); 224 | }); 225 | it('should skip due to the user-agent header', async () => { 226 | handler = function userAgentSkip() { 227 | return 'string'; 228 | }; 229 | reqMock['headers'] = { 230 | 'user-agent': 'userAgentIgnore', 231 | }; 232 | const ctxMock = contextMockFactory('http', handler, { 233 | getResponse: () => resMock, 234 | getRequest: () => reqMock, 235 | }); 236 | const canActivate = await guard.canActivate(ctxMock); 237 | expect(canActivate).toBe(true); 238 | expect(headerSettingMock).toBeCalledTimes(0); 239 | }); 240 | it('should accept callback options for ttl and limit', async () => { 241 | const modRef = await Test.createTestingModule({ 242 | providers: [ 243 | ThrottlerGuard, 244 | { 245 | provide: THROTTLER_OPTIONS, 246 | useValue: [ 247 | { 248 | limit: () => 5, 249 | ttl: () => 60, 250 | ignoreUserAgents: [/userAgentIgnore/], 251 | }, 252 | ], 253 | }, 254 | { 255 | provide: ThrottlerStorage, 256 | useClass: ThrottlerStorageServiceMock, 257 | }, 258 | { 259 | provide: Reflector, 260 | useValue: { 261 | getAllAndOverride: jest.fn(), 262 | }, 263 | }, 264 | ], 265 | }).compile(); 266 | const guard = modRef.get(ThrottlerGuard); 267 | await guard.onModuleInit(); 268 | handler = function addHeaders() { 269 | return 'string'; 270 | }; 271 | const ctxMock = contextMockFactory('http', handler, { 272 | getResponse: () => resMock, 273 | getRequest: () => reqMock, 274 | }); 275 | const canActivate = await guard.canActivate(ctxMock); 276 | expect(canActivate).toBe(true); 277 | expect(headerSettingMock).toBeCalledTimes(3); 278 | expect(headerSettingMock).toHaveBeenNthCalledWith(1, 'X-RateLimit-Limit', 5); 279 | expect(headerSettingMock).toHaveBeenNthCalledWith(2, 'X-RateLimit-Remaining', 4); 280 | expect(headerSettingMock).toHaveBeenNthCalledWith(3, 'X-RateLimit-Reset', expect.any(Number)); 281 | }); 282 | it('should not add headers to the response when setHeaders is false', async () => { 283 | const modRef = await Test.createTestingModule({ 284 | providers: [ 285 | ThrottlerGuard, 286 | { 287 | provide: THROTTLER_OPTIONS, 288 | useValue: [ 289 | { 290 | limit: 5, 291 | ttl: 60, 292 | setHeaders: false, 293 | }, 294 | ], 295 | }, 296 | { 297 | provide: ThrottlerStorage, 298 | useClass: ThrottlerStorageServiceMock, 299 | }, 300 | { 301 | provide: Reflector, 302 | useValue: { 303 | getAllAndOverride: jest.fn(), 304 | }, 305 | }, 306 | ], 307 | }).compile(); 308 | 309 | const guard = modRef.get(ThrottlerGuard); 310 | await guard.onModuleInit(); 311 | 312 | const headerSettingMock = jest.fn(); 313 | const resMock = { 314 | header: headerSettingMock, 315 | }; 316 | const reqMock = { 317 | headers: {}, 318 | }; 319 | 320 | handler = function noHeaders() { 321 | return 'string'; 322 | }; 323 | 324 | const ctxMock = contextMockFactory('http', handler, { 325 | getResponse: () => resMock, 326 | getRequest: () => reqMock, 327 | }); 328 | 329 | for (let i = 0; i < 5; i++) { 330 | const canActivate = await guard.canActivate(ctxMock); 331 | expect(canActivate).toBe(true); 332 | } 333 | 334 | expect(headerSettingMock).not.toHaveBeenCalled(); 335 | 336 | await expect(guard.canActivate(ctxMock)).rejects.toThrowError(ThrottlerException); 337 | 338 | expect(headerSettingMock).not.toHaveBeenCalled(); 339 | }); 340 | it('should respect setHeaders option from commonOptions', async () => { 341 | const modRef = await Test.createTestingModule({ 342 | providers: [ 343 | ThrottlerGuard, 344 | { 345 | provide: THROTTLER_OPTIONS, 346 | useValue: { 347 | throttlers: [ 348 | { 349 | limit: 5, 350 | ttl: 60, 351 | }, 352 | ], 353 | setHeaders: false, 354 | }, 355 | }, 356 | { 357 | provide: ThrottlerStorage, 358 | useClass: ThrottlerStorageServiceMock, 359 | }, 360 | { 361 | provide: Reflector, 362 | useValue: { 363 | getAllAndOverride: jest.fn(), 364 | }, 365 | }, 366 | ], 367 | }).compile(); 368 | 369 | const guard = modRef.get(ThrottlerGuard); 370 | await guard.onModuleInit(); 371 | 372 | handler = function commonOptionsTest() { 373 | return 'string'; 374 | }; 375 | 376 | const ctxMock = contextMockFactory('http', handler, { 377 | getResponse: () => resMock, 378 | getRequest: () => reqMock, 379 | }); 380 | 381 | const canActivate = await guard.canActivate(ctxMock); 382 | expect(canActivate).toBe(true); 383 | expect(headerSettingMock).not.toHaveBeenCalled(); 384 | }); 385 | }); 386 | }); 387 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.4.0 2 | 3 | ## 6.5.0 4 | 5 | ### Minor Changes 6 | 7 | - 58964d6: Add a setHeaders option to control whether to add headers to the response or not 8 | 9 | ### Minor Changes 10 | 11 | - 5cb4254: Update to allow for support for Nest version 11 12 | 13 | ## 6.3.0 14 | 15 | ### Minor Changes 16 | 17 | - fc93f3a: pass context to getTraker as a second arg 18 | 19 | ## 6.2.1 20 | 21 | ### Patch Changes 22 | 23 | - fbf27c6: Add the guard interfaces for export for public use 24 | 25 | ## 6.2.0 26 | 27 | ### Minor Changes 28 | 29 | - 3d1a9a5: Swap MD5 hash for SHA256 to better support OpenSSL 3.0 and future iterations 30 | 31 | ## 6.1.1 32 | 33 | ### Patch Changes 34 | 35 | - ef69348: Update the readme for websockets 36 | 37 | ## 6.1.0 38 | 39 | ### Minor Changes 40 | 41 | - e058d50: Use ceil instead of floor while calculating expire and block expire at to properly account for rounding up instead of down and accidentally allowing for early continued requests. Related to #2074 42 | 43 | ## 6.0.0 44 | 45 | ### Major Changes 46 | 47 | - 93b62d2: A time will be provided to block the request separately from the ttl. There is a breaking change at the library level. Storage library owners will be affected by this breaking change 48 | - 9b3f9cd: - e17a5dc: The storage has been updated to utilize Map instead of a simple object for key-value storage. This enhancement offers improved performance, especially for scenarios involving frequent additions and deletions of keys. There is a breaking change at the library level. Storage library owners will be affected by this breaking change 49 | 50 | ## 5.2.0 51 | 52 | ### Minor Changes 53 | 54 | - 16467c1: Add dynamic error messages based on context and ThrottlerLimitDetail 55 | 56 | ## 5.1.2 57 | 58 | ### Patch Changes 59 | 60 | - 7a431e5: Improve performance by replacing md5 npm package with Node.js crypto module. 61 | 62 | ## 5.1.1 63 | 64 | ### Patch Changes 65 | 66 | - b06a208: Resolves a bug that cause 'this' to be undefined in the 'getTracker' and 'generateKey' methods of the custom ThrottlerGuard 67 | 68 | ## 5.1.0 69 | 70 | ### Minor Changes 71 | 72 | - 903d187: Allow for throttler definitions to define their own trackers and key generators to allow for more customization of the rate limit process 73 | 74 | ## 5.0.1 75 | 76 | ### Patch Changes 77 | 78 | - bc9e6b2: Correctly assign metadata for multiple throttlers passed to `@SkipThrottle()` 79 | 80 | ### Major Changes 81 | 82 | - 2f4f2a7: # FEATURES 83 | - allow for multiple Throttler Contexts 84 | - allow for conditionally skipping based on `ThrottleGuard#shouldSkip` method 85 | - allow for easily overriding throttler message based on guard method 86 | - extra context passed to throw method for better customization of message 87 | - `ThrottlerStorage` no longer needs a `storage` property` 88 | - `getTracker` can now be async 89 | 90 | # BREAKING CHANGES 91 | - ttl is now in milliseconds, not seconds, but there are time helper exposed to 92 | ease the migration to that 93 | - the module options is now either an array or an object with a `throttlers` 94 | array property 95 | - `@Throttle()` now takes in an object instead of two parameters, to allow for 96 | setting multiple throttle contexts at once in a more readable manner 97 | - `@ThrottleSkip()` now takes in an object with string boolean to say which 98 | throttler should be skipped 99 | - `ttl` and `limit` are no longer optional in the module's options. If an option 100 | object is passed, it **must** define the defaults for that throttler 101 | 102 | # HOW TO MIGRATE 103 | 104 | For most people, wrapping your options in an array will be enough. 105 | 106 | If you are using a custom storage, you should wrap you `ttl` and `limit` in an 107 | array and assign it to the `throttlers` property of the options object. 108 | 109 | Any `@ThrottleSkip()` should now take in an object with `string: boolean` props. 110 | The strings are the names of the throttlers. If you do not have a name, pass the 111 | string `'default'`, as this is what will be used under the hood otherwise. 112 | 113 | Any `@Throttle()` decorators should also now take in an object with string keys, 114 | relating to the names of the throttler contexts (again, `'default'` if no name) 115 | and values of objects that have `limit` and `ttl` keys. 116 | 117 | **IMPORTANT**: The `ttl` is now in **miliseconds**. If you want to keep your ttl 118 | in seconds for readability, usethe `seconds` helper from this package. It just 119 | multiplies the ttl by 1000 to make it in milliseconds. 120 | 121 | ## 4.2.1 122 | 123 | ### Patch Changes 124 | 125 | - b72c9cb: Revert resolvable properties for ttl and limit 126 | 127 | The resolvable properties made a breaking change for custom guards that was 128 | unforseen. This reverts it and schedules the changes for 5.0.0 instead 129 | 130 | ## 4.2.0 131 | 132 | ### Minor Changes 133 | 134 | - d8d8c93: Allow for ttl and limit to be set based on the execution context, instead of statically assigned for the entire application 135 | 136 | ## 4.1.0 137 | 138 | ### Minor Changes 139 | 140 | - 527d51c: Support Nest v10 141 | 142 | ## 4.0.0 143 | 144 | ### Major Changes 145 | 146 | - 4803dda: Rewrite the storage service to better handle large numbers of operations 147 | 148 | ## Why 149 | 150 | The initial behavior was that `getRecord()` returned an list of sorted TTL 151 | timestamps, then if it didn't reach the limit, it will call `addRecord()`. 152 | This change was made based on the use of the Redis storage community package 153 | where it was found how to prevent this issue. It was found out that 154 | [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) 155 | is incrementing a single number and returning the information in a single 156 | roundtrip, which is significantly faster than how NestJS throttler works by 157 | called `getRecord()`, then `addRecord`. 158 | 159 | ## Breaking Changes 160 | - removed `getRecord` 161 | - `addRecord(key: string, ttl: number): Promise;` changes to `increment(key: string, ttl: number): Promise;` 162 | 163 | ## How to Migrate 164 | 165 | If you are just _using_ the throttler library, you're already covered. No 166 | changes necessary to your code, version 4.0.0 will work as is. 167 | 168 | If you are providing a custom storage, you will need to remove your current 169 | service's `getRecord` method and rename `addRecord` to `incremenet` while 170 | adhering to the new interface and returning an `ThrottlerStorageRecord` object 171 | 172 | ## 3.1.0 173 | 174 | ### Minor Changes 175 | 176 | - da3c950: Add `skipIf` option to throttler module options 177 | 178 | With the new option, you can pass a factory to `skipIf` and determine if the throttler guard should be used in the first palce or not. This acts just like applying `@SkipThrottle()` to every route, but can be customized to work off of the `process.env` or `ExecutionContext` object to provide better support for dev and QA environments. 179 | 180 | ## 3.0.0 181 | 182 | ### Major Changes 183 | 184 | - c9fcd51: Upgrade nest version to v9. No breaking changes in direct code, but in nest v9 upgrade 185 | 186 | ## 2.0.1 187 | 188 | ### Patch Changes 189 | 190 | - cf50808: fix memory leak for timeoutIds array. Before this, the timeoutIds array would not be trimmed and would grow until out of memory. Now ids are properly removed on timeout. 191 | 192 | ### Features 193 | 194 | - adding in a comment about version ([b13bf53](https://github.com/nestjs/throttler/commit/b13bf53542236ba6b05ac537b7a677e1644a0407)) 195 | 196 | ### BREAKING CHANGES 197 | 198 | - v2 and above is now being developed specificially for 199 | nest v8 and could have some unforseen side effects with Nest v7. use with 200 | v7 at your own risk. 201 | 202 | ## [1.2.1](https://github.com/nestjs/throttler/compare/v1.2.0...v1.2.1) (2021-07-09) 203 | 204 | ### Performance Improvements 205 | 206 | - upgrade to nest v8 ([cb5dd91](https://github.com/nestjs/throttler/commit/cb5dd913e9fcc482cd74f2d49085b98dac630215)) 207 | 208 | # [0.3.0](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.3...0.3.0) (2020-11-10) 209 | 210 | ### Bug Fixes 211 | 212 | - **module:** async register is now `forRootAsync` ([a1c6ace](https://github.com/jmcdo29/nestjs-throttler/commit/a1c6acef472e9d2368f2139e6b789ef184a7d952)) 213 | 214 | ## [0.2.3](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.2...0.2.3) (2020-08-06) 215 | 216 | ### Features 217 | 218 | - **ws:** allows for optional use of @nestjs/websocket ([f437614](https://github.com/jmcdo29/nestjs-throttler/commit/f437614cab5aebfdfdb4d5884f45b58b16d5a140)) 219 | 220 | ## [0.2.2](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.1...0.2.2) (2020-06-12) 221 | 222 | ### Bug Fixes 223 | 224 | - moves userAgent check to http handler ([87183af](https://github.com/jmcdo29/nestjs-throttler/commit/87183af8fc189d7d5c8237832089138a0b40589b)) 225 | 226 | ### Features 227 | 228 | - **decorator:** add setThrottlerMetadata() function back ([ea31a9c](https://github.com/jmcdo29/nestjs-throttler/commit/ea31a9c86b82550e2d43f3433ec618785cf2b34a)) 229 | - **graphql:** implements graphql limiter ([40eaff1](https://github.com/jmcdo29/nestjs-throttler/commit/40eaff16dae5c0279001e56ff64a2b540d82a3c7)) 230 | - Add support for ws (websockets) ([a745295](https://github.com/jmcdo29/nestjs-throttler/commit/a74529517f989c43d77c9a63712e82244ebeefcd)) 231 | - Add support for ws (websockets) ([8103a5a](https://github.com/jmcdo29/nestjs-throttler/commit/8103a5a11c1916f05f8c44e302ba93a98d7cb77d)) 232 | - Make storage methods async ([92cd4eb](https://github.com/jmcdo29/nestjs-throttler/commit/92cd4ebf507b3bed4efbaeb7bb47bd1738a62dc3)) 233 | - **exception:** Use const instead of duplicated string ([f95da2c](https://github.com/jmcdo29/nestjs-throttler/commit/f95da2c4fc787c7c5e525672d668745bc1f2301d)) 234 | - **guard:** Add default case for context.getType() switch ([ff46d57](https://github.com/jmcdo29/nestjs-throttler/commit/ff46d57508c4b446918ccd75f704d0eed1ae352f)) 235 | - Implement basic support for websocket ([3a0cf2e](https://github.com/jmcdo29/nestjs-throttler/commit/3a0cf2ed70c7abbe02e9d96f26ab2c81b3c7bb2f)) 236 | 237 | ## [0.2.1](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.0...0.2.1) (2020-06-09) 238 | 239 | ### Features 240 | 241 | - add support for ignoreUserAgents option ([1ab5e17](https://github.com/jmcdo29/nestjs-throttler/commit/1ab5e17a25a95ec14910e199726eac07f66f4475)) 242 | 243 | # [0.2.0](https://github.com/jmcdo29/nestjs-throttler/compare/0.1.1...0.2.0) (2020-06-09) 244 | 245 | ### Bug Fixes 246 | 247 | - make core module global and export core module inside ThrottlerModule ([1f4df42](https://github.com/jmcdo29/nestjs-throttler/commit/1f4df42a5fc9a6f75c398bbb6a3f9ebaec6bc80f)) 248 | 249 | ### Features 250 | 251 | - makes options required in forRoot and forRootAsync ([14e272a](https://github.com/jmcdo29/nestjs-throttler/commit/14e272a842a90db93dd9e8c60c936fbcf0bcd3b7)) 252 | - remove global guard and require user to implement it manually ([840eae4](https://github.com/jmcdo29/nestjs-throttler/commit/840eae4643867390bc598937b20e132257e9b018)) 253 | 254 | ## [0.1.1](https://github.com/jmcdo29/nestjs-throttler/compare/0.1.0...0.1.1) (2020-06-07) 255 | 256 | ### Bug Fixes 257 | 258 | - **interface:** fixes the storage interface to be async ([f7565d9](https://github.com/jmcdo29/nestjs-throttler/commit/f7565d9029baf4d7687f0913046f555d17cde44b)) 259 | 260 | # 0.1.0 (2020-06-07) 261 | 262 | ### Bug Fixes 263 | 264 | - adds back AppModule to allow for running server for tests ([5af229b](https://github.com/jmcdo29/nestjs-throttler/commit/5af229ba69527daf3662b1899ed985fa9404251b)) 265 | - updates some types ([b26fc06](https://github.com/jmcdo29/nestjs-throttler/commit/b26fc06841a430e5728cde6276515130b89a7289)) 266 | - updates storage interface to use number ([339f29c](https://github.com/jmcdo29/nestjs-throttler/commit/339f29c12b4720a7376ec042988f73460172b32e)) 267 | - updates tests and resolves comments from pr ([ee87e05](https://github.com/jmcdo29/nestjs-throttler/commit/ee87e05e2f5eb61b00b423d6394be9a131f84f8a)) 268 | - **.gitignore:** Ignore all dist and node_modules rather than root-level only ([d9609af](https://github.com/jmcdo29/nestjs-throttler/commit/d9609afb9cf3561b84082ac9a3e2e26ddcbb2117)) 269 | - **guard:** Change RateLimit header prefix to X-RateLimit ([328c0a3](https://github.com/jmcdo29/nestjs-throttler/commit/328c0a3c1009fdc65820125c2145de65aebd3fee)) 270 | - **guard:** Change RateLimit header prefix to X-RateLimit ([3903885](https://github.com/jmcdo29/nestjs-throttler/commit/3903885df9eaac0d966c5b8207fae26b62f337f3)) 271 | - **guard:** guard now binds globally without the use of @UseGuards() ([4022447](https://github.com/jmcdo29/nestjs-throttler/commit/40224475d27f1ec0cf792225bbc18df33ab14cc2)) 272 | - **guard:** guard now binds globally without the use of @UseGuards() ([3ca146d](https://github.com/jmcdo29/nestjs-throttler/commit/3ca146d41afa71e3c68b73d8706e7431f929a85a)) 273 | - **guard:** Prevent RateLimit-Remaining from going below 0 ([25e33c8](https://github.com/jmcdo29/nestjs-throttler/commit/25e33c882007892a3285c92449aa5bc0840a8909)) 274 | - **guard:** Prevent RateLimit-Remaining from going below 0 ([74b1668](https://github.com/jmcdo29/nestjs-throttler/commit/74b166888ab283281a964d6c64b94224e2f96ba4)) 275 | - **guard:** Use the correct approach to check for excluded routes ([38eac3c](https://github.com/jmcdo29/nestjs-throttler/commit/38eac3ca3bdad0b4b266587bc4b0287f3f69f640)) 276 | - **guard:** Use the correct approach to check for excluded routes ([912813f](https://github.com/jmcdo29/nestjs-throttler/commit/912813f49cc98e8fbd2643650d22ea8cc88c77ae)) 277 | - req.method value in httpPromise ([b9ee26e](https://github.com/jmcdo29/nestjs-throttler/commit/b9ee26e5e888e4d4f220e91adc996ade764f7002)) 278 | 279 | ### Features 280 | 281 | - Swap excludeRoutes for @SkipThrottle() decorator ([16d6fac](https://github.com/jmcdo29/nestjs-throttler/commit/16d6facd5e8f648620fa47e372078db37472f619)) 282 | - **fastify:** updates guard to work for fastify ([bc678a3](https://github.com/jmcdo29/nestjs-throttler/commit/bc678a363c367d132a90a2a4282e3f033f526e00)) 283 | - Implement ignoreRoutes functionality ([7b8ab42](https://github.com/jmcdo29/nestjs-throttler/commit/7b8ab4273fffafc0dd0571393d8c0faf89afc42f)) 284 | - **package.json:** Add --watch to start:dev script ([3c4c28a](https://github.com/jmcdo29/nestjs-throttler/commit/3c4c28abbb324e064f65b284f1a99683cd02030b)) 285 | - Implement ignoreRoutes functionality ([75f870c](https://github.com/jmcdo29/nestjs-throttler/commit/75f870c5b49e4d22c70519d28f8efffc1da288eb)) 286 | - **module:** implements start of limiter module ([35dbff5](https://github.com/jmcdo29/nestjs-throttler/commit/35dbff5d30e7a1385a4f4cf688992017eb7e0566)) 287 | - **package.json:** Add --watch to start:dev script ([a6b441c](https://github.com/jmcdo29/nestjs-throttler/commit/a6b441cad221b7eee52be0ba81c66fca81853c4f)) 288 | - Add global ThrottlerGuard ([9a84aff](https://github.com/jmcdo29/nestjs-throttler/commit/9a84afff5d57a16731d021cb47d60c2b4d02eb02)) 289 | - adds httpromise for async/await http calls in tests ([70210c7](https://github.com/jmcdo29/nestjs-throttler/commit/70210c76173aabfd5f85f5a24e624e7c4c010ae2)) 290 | - Rename certain variables to use the THROTTLER prefix ([6a21b21](https://github.com/jmcdo29/nestjs-throttler/commit/6a21b216a2738aa470e2138d44053ba8413ce117)) 291 | - Setup example app ([df6b5f6](https://github.com/jmcdo29/nestjs-throttler/commit/df6b5f633ebbb4770d3eb9e72e8075cbe6b2f78a)) 292 | - Setup example app ([30c7576](https://github.com/jmcdo29/nestjs-throttler/commit/30c75764fd20f3afe7a3f7533a3f4f08d275a741)) 293 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 |

A progressive Node.js framework for building efficient and scalable server-side applications.

6 |

7 | NPM Version 8 | Package License 9 | NPM Downloads 10 | Coverage 11 | Discord 12 | Backers on Open Collective 13 | Sponsors on Open Collective 14 | 15 |

16 | 17 | ## Description 18 | 19 | A Rate-Limiter for NestJS, regardless of the context. 20 | 21 | Throttler ensures that users can only make `limit` requests per `ttl` to each endpoint. By default, users are identified by their IP address. This behavior can be customized by providing your own `getTracker` function. See [Proxies](#proxies) for an example where this is useful. 22 | 23 | Throttler comes with a built-in in-memory cache to keep track of the requests. It supports alternate storage providers. For an overview, see [Community Storage Providers](#community-storage-providers). 24 | 25 | ## Installation 26 | 27 | ```bash 28 | $ npm i --save @nestjs/throttler 29 | ``` 30 | 31 | ## Versions 32 | 33 | `@nestjs/throttler@^1` is compatible with Nest v7 while `@nestjs/throttler@^2` is compatible with Nest v7 and Nest v8, but it is suggested to be used with only v8 in case of breaking changes against v7 that are unseen. 34 | 35 | For NestJS v10, please use version 4.1.0 or above. 36 | 37 | ## Usage 38 | 39 | ### ThrottlerModule 40 | 41 | Once the installation is complete, the `ThrottlerModule` can be configured as any other Nest package with `forRoot` or `forRootAsync` methods. 42 | 43 | ```typescript 44 | @@filename(app.module) 45 | @Module({ 46 | imports: [ 47 | ThrottlerModule.forRoot([{ 48 | ttl: 60000, 49 | limit: 10, 50 | }]), 51 | ], 52 | }) 53 | export class AppModule {} 54 | ``` 55 | 56 | The above will set the global options for the `ttl`, the time to live in milliseconds, and the `limit`, the maximum number of requests within the ttl, for the routes of your application that are guarded. 57 | 58 | Once the module has been imported, you can then choose how you would like to bind the `ThrottlerGuard`. Any kind of binding as mentioned in the [guards](https://docs.nestjs.com/guards) section is fine. If you wanted to bind the guard globally, for example, you could do so by adding this provider to any module: 59 | 60 | ```typescript 61 | { 62 | provide: APP_GUARD, 63 | useClass: ThrottlerGuard 64 | } 65 | ``` 66 | 67 | #### Multiple Throttler Definitions 68 | 69 | There may come upon times where you want to set up multiple throttling definitions, like no more than 3 calls in a second, 20 calls in 10 seconds, and 100 calls in a minute. To do so, you can set up your definitions in the array with named options, that can later be referenced in the `@SkipThrottle()` and `@Throttle()` decorators to change the options again. 70 | 71 | ```typescript 72 | @@filename(app.module) 73 | @Module({ 74 | imports: [ 75 | ThrottlerModule.forRoot([ 76 | { 77 | name: 'short', 78 | ttl: 1000, 79 | limit: 3, 80 | }, 81 | { 82 | name: 'medium', 83 | ttl: 10000, 84 | limit: 20 85 | }, 86 | { 87 | name: 'long', 88 | ttl: 60000, 89 | limit: 100 90 | } 91 | ]), 92 | ], 93 | }) 94 | export class AppModule {} 95 | ``` 96 | 97 | ### Customization 98 | 99 | There may be a time where you want to bind the guard to a controller or globally, but want to disable rate limiting for one or more of your endpoints. For that, you can use the `@SkipThrottle()` decorator, to negate the throttler for an entire class or a single route. The `@SkipThrottle()` decorator can also take in an object of string keys with boolean values, if you have more than one throttler set. If you do not pass an object, the default is to use `{ default: true }` 100 | 101 | ```typescript 102 | @SkipThrottle() 103 | @Controller('users') 104 | export class UsersController {} 105 | ``` 106 | 107 | This `@SkipThrottle()` decorator can be used to skip a route or a class or to negate the skipping of a route in a class that is skipped. 108 | 109 | ```typescript 110 | @SkipThrottle() 111 | @Controller('users') 112 | export class UsersController { 113 | // Rate limiting is applied to this route. 114 | @SkipThrottle({ default: false }) 115 | dontSkip() { 116 | return 'List users work with Rate limiting.'; 117 | } 118 | // This route will skip rate limiting. 119 | doSkip() { 120 | return 'List users work without Rate limiting.'; 121 | } 122 | } 123 | ``` 124 | 125 | There is also the `@Throttle()` decorator which can be used to override the `limit` and `ttl` set in the global module, to give tighter or looser security options. This decorator can be used on a class or a function as well. With version 5 and onwards, the decorator takes in an object with the string relating to the name of the throttler set, and an object with the limit and ttl keys and integer values, similar to the options passed to the root module. If you do not have a name set in your original options, use the string `default` You have to configure it like this: 126 | 127 | ```typescript 128 | // Override default configuration for Rate limiting and duration. 129 | @Throttle({ default: { limit: 3, ttl: 60000 } }) 130 | @Get() 131 | findAll() { 132 | return "List users works with custom rate limiting."; 133 | } 134 | ``` 135 | 136 | ### Proxies 137 | 138 | If your application runs behind a proxy server, check the specific HTTP adapter options ([express](http://expressjs.com/en/guide/behind-proxies.html) and [fastify](https://www.fastify.io/docs/latest/Reference/Server/#trustproxy)) for the `trust proxy` option and enable it. Doing so will allow you to get the original IP address from the `X-Forwarded-For` header. 139 | 140 | For express, no further configuration is needed because express sets `req.ip` to the client IP if `trust proxy` is enabled. For fastify, you need to read the client IP from `req.ips` instead. The following example is only needed for fastify, but works with both engines: 141 | 142 | ```typescript 143 | // throttler-behind-proxy.guard.ts 144 | import { ThrottlerGuard } from '@nestjs/throttler'; 145 | import { Injectable } from '@nestjs/common'; 146 | 147 | @Injectable() 148 | export class ThrottlerBehindProxyGuard extends ThrottlerGuard { 149 | protected getTracker(req: Record): Promise { 150 | // The client IP is the leftmost IP in req.ips. You can individualize IP 151 | // extraction to meet your own needs. 152 | const tracker = req.ips.length > 0 ? req.ips[0] : req.ip; 153 | return Promise.resolve(tracker); 154 | } 155 | } 156 | 157 | // app.controller.ts 158 | import { ThrottlerBehindProxyGuard } from './throttler-behind-proxy.guard'; 159 | 160 | @UseGuards(ThrottlerBehindProxyGuard) 161 | ``` 162 | 163 | > **Hint:** You can find the API of the `req` Request object for express [here](https://expressjs.com/en/api.html#req.ips) and for fastify [here](https://www.fastify.io/docs/latest/Reference/Request/). 164 | 165 | ### Websockets 166 | 167 | This module can work with websockets, but it requires some class extension. You can extend the `ThrottlerGuard` and override the `handleRequest` method like so: 168 | 169 | ```typescript 170 | @Injectable() 171 | export class WsThrottlerGuard extends ThrottlerGuard { 172 | async handleRequest(requestProps: ThrottlerRequest): Promise { 173 | const { context, limit, ttl, throttler, blockDuration, generateKey } = requestProps; 174 | 175 | const client = context.switchToWs().getClient(); 176 | const tracker = client._socket.remoteAddress; 177 | const key = generateKey(context, tracker, throttler.name); 178 | const { totalHits, timeToExpire, isBlocked, timeToBlockExpire } = 179 | await this.storageService.increment(key, ttl, limit, blockDuration, throttler.name); 180 | 181 | // Throw an error when the user reached their limit. 182 | if (isBlocked) { 183 | await this.throwThrottlingException(context, { 184 | limit, 185 | ttl, 186 | key, 187 | tracker, 188 | totalHits, 189 | timeToExpire, 190 | isBlocked, 191 | timeToBlockExpire, 192 | }); 193 | } 194 | 195 | return true; 196 | } 197 | } 198 | ``` 199 | 200 | > **Hint:** If you are using ws, it is necessary to replace the `_socket` with `conn`. 201 | 202 | There's a few things to keep in mind when working with WebSockets: 203 | 204 | - Guard cannot be registered with the `APP_GUARD` or `app.useGlobalGuards()` 205 | - When a limit is reached, Nest will emit an `exception` event, so make sure there is a listener ready for this 206 | 207 | > **Hint:** If you are using the `@nestjs/platform-ws` package you can use `client._socket.remoteAddress` instead. 208 | 209 | ### GraphQL 210 | 211 | The `ThrottlerGuard` can also be used to work with GraphQL requests. Again, the guard can be extended, but this time the `getRequestResponse` method will be overridden: 212 | 213 | ```typescript 214 | @Injectable() 215 | export class GqlThrottlerGuard extends ThrottlerGuard { 216 | getRequestResponse(context: ExecutionContext) { 217 | const gqlCtx = GqlExecutionContext.create(context); 218 | const ctx = gqlCtx.getContext(); 219 | return { req: ctx.req, res: ctx.res }; 220 | } 221 | } 222 | ``` 223 | 224 | However, when using Apollo Express/Fastify or Mercurius, it's important to configure the context correctly in the GraphQLModule to avoid any problems. 225 | 226 | #### Apollo Server (for Express): 227 | 228 | For Apollo Server running on Express, you can set up the context in your GraphQLModule configuration as follows: 229 | 230 | ```typescript 231 | GraphQLModule.forRoot({ 232 | // ... other GraphQL module options 233 | context: ({ req, res }) => ({ req, res }), 234 | }); 235 | ``` 236 | 237 | #### Apollo Server (for Fastify) & Mercurius: 238 | 239 | When using Apollo Server with Fastify or Mercurius, you need to configure the context differently. You should use request and reply objects. Here's an example: 240 | 241 | ```typescript 242 | GraphQLModule.forRoot({ 243 | // ... other GraphQL module options 244 | context: (request, reply) => ({ request, reply }), 245 | }); 246 | ``` 247 | 248 | ### Configuration 249 | 250 | The following options are valid for the object passed to the array of the `ThrottlerModule`'s options: 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 |
namethe name for internal tracking of which throttler set is being used. Defaults to `default` if not passed
ttlthe number of milliseconds that each request will last in storage
limitthe maximum number of requests within the TTL limit
blockDurationthe number of milliseconds the request will be blocked
ignoreUserAgentsan array of regular expressions of user-agents to ignore when it comes to throttling requests
skipIfa function that takes in the ExecutionContext and returns a boolean to short circuit the throttler logic. Like @SkipThrottler(), but based on the request
getTrackera function that takes in the Request and ExecutionContext, and returns a string to override the default logic of the getTracker method
generateKeya function that takes in the ExecutionContext, the tracker string and the throttler name as a string and returns a string to override the final key which will be used to store the rate limit value. This overrides the default logic of the generateKey method
286 | 287 | If you need to set up storages instead, or want to use a some of the above options in a more global sense, applying to each throttler set, you can pass the options above via the `throttlers` option key and use the below table 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 |
storagea custom storage service for where the throttling should be kept track. See Storages below.
ignoreUserAgentsan array of regular expressions of user-agents to ignore when it comes to throttling requests
skipIfa function that takes in the ExecutionContext and returns a boolean to short circuit the throttler logic. Like @SkipThrottler(), but based on the request
throttlersan array of throttler sets, defined using the table above
errorMessagea string OR a function that takes in the ExecutionContext and the ThrottlerLimitDetail and returns a string which overrides the default throttler error message
getTrackera function that takes in the Request and ExecutionContext, and returns a string to override the default logic of the getTracker method
generateKeya function that takes in the ExecutionContext, the tracker string and the throttler name as a string and returns a string to override the final key which will be used to store the rate limit value. This overrides the default logic of the generateKey method
319 | 320 | #### Async Configuration 321 | 322 | You may want to get your rate-limiting configuration asynchronously instead of synchronously. You can use the `forRootAsync()` method, which allows for dependency injection and `async` methods. 323 | 324 | One approach would be to use a factory function: 325 | 326 | ```typescript 327 | @Module({ 328 | imports: [ 329 | ThrottlerModule.forRootAsync({ 330 | imports: [ConfigModule], 331 | inject: [ConfigService], 332 | useFactory: (config: ConfigService) => [ 333 | { 334 | ttl: config.get('THROTTLE_TTL'), 335 | limit: config.get('THROTTLE_LIMIT'), 336 | }, 337 | ], 338 | }), 339 | ], 340 | }) 341 | export class AppModule {} 342 | ``` 343 | 344 | You can also use the `useClass` syntax: 345 | 346 | ```typescript 347 | @Module({ 348 | imports: [ 349 | ThrottlerModule.forRootAsync({ 350 | imports: [ConfigModule], 351 | useClass: ThrottlerConfigService, 352 | }), 353 | ], 354 | }) 355 | export class AppModule {} 356 | ``` 357 | 358 | This is doable, as long as `ThrottlerConfigService` implements the interface `ThrottlerOptionsFactory`. 359 | 360 | ### Storages 361 | 362 | The built in storage is an in memory cache that keeps track of the requests made until they have passed the TTL set by the global options. You can drop in your own storage option to the `storage` option of the `ThrottlerModule` so long as the class implements the `ThrottlerStorage` interface. 363 | 364 | > **Note:** `ThrottlerStorage` can be imported from `@nestjs/throttler`. 365 | 366 | ### Time Helpers 367 | 368 | There are a couple of helper methods to make the timings more readable if you prefer to use them over the direct definition. `@nestjs/throttler` exports five different helpers, `seconds`, `minutes`, `hours`, `days`, and `weeks`. To use them, simply call `seconds(5)` or any of the other helpers, and the correct number of milliseconds will be returned. 369 | 370 | ### Migrating to v5 from earlier versions 371 | 372 | If you migrate to v5 from earlier versions, you need to wrap your options in an array. 373 | 374 | If you are using a custom storage, you should wrap you `ttl` and `limit` in an array and assign it to the `throttlers` property of the options object. 375 | 376 | Any `@ThrottleSkip()` should now take in an object with `string: boolean` props. The strings are the names of the throttlers. If you do not have a name, pass the string `'default'`, as this is what will be used under the hood otherwise. 377 | 378 | Any `@Throttle()` decorators should also now take in an object with string keys, relating to the names of the throttler contexts (again, `'default'` if no name) and values of objects that have `limit` and `ttl` keys. 379 | 380 | > **Important:** The `ttl` is now in **milliseconds**. If you want to keep your ttl in seconds for readability, use the `seconds` helper from this package. It just multiplies the ttl by 1000 to make it in milliseconds. 381 | 382 | For more info, see the [Changelog](https://github.com/nestjs/throttler/blob/master/CHANGELOG.md#500) 383 | 384 | ## Community Storage Providers 385 | 386 | - [Redis](https://github.com/CSenshi/nestjs-redis/tree/main/packages/throttler-storage) (`node-redis` based) 387 | - [Redis](https://github.com/jmcdo29/nest-lab/tree/main/packages/throttler-storage-redis) (`ioredis` based) 388 | - [Mongo](https://www.npmjs.com/package/nestjs-throttler-storage-mongo) 389 | 390 | Feel free to submit a PR with your custom storage provider being added to this list. 391 | 392 | ## License 393 | 394 | Nest is [MIT licensed](LICENSE). 395 | 396 |

🔼 Back to TOC

397 | --------------------------------------------------------------------------------