diff --git a/src/errors.ts b/src/errors.ts index 6af0119..1c35ffb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -17,7 +17,7 @@ import type { LimiterResponse } from './response.js' * Throttle exception is raised when the user has exceeded * the number of requests allowed during a given duration */ -class ThrottleException extends Exception { +export class ThrottleException extends Exception { message = 'Too many requests' status = 429 code = 'E_TOO_MANY_REQUESTS' diff --git a/src/http_limiter.ts b/src/http_limiter.ts new file mode 100644 index 0000000..0b5561e --- /dev/null +++ b/src/http_limiter.ts @@ -0,0 +1,183 @@ +/* + * @adonisjs/limiter + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import { RuntimeException } from '@adonisjs/core/exceptions' + +import debug from './debug.js' +import { LimiterResponse } from './response.js' +import type { LimiterManager } from './limiter_manager.js' +import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.js' +import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js' + +/** + * HttpLimiter is a special type of limiter instance created specifically + * for HTTP requests. It exposes a single method to throttle the request + * using the request ip address or the pre-defined unique key. + */ +export class HttpLimiter> { + /** + * A unique name to prefix keys for the given + * HTTP limiter + */ + #name: string + + /** + * Reference to the HTTP context for which the Limiter + * instance was created + */ + #ctx: HttpContext + + /** + * The manager reference to create limiter instances + * for a given store + */ + #manager: LimiterManager + + /** + * The runtime options configured using the fluent + * API + */ + #options?: Partial + + /** + * The selected store. Otherwise the default store will + * be used + */ + #store?: keyof KnownStores + + /** + * The key to unique identify the user. Defaults to "request.ip" + */ + #key?: string | number + + /** + * A custom callback function to modify error messages. + */ + #exceptionModifier: (error: ThrottleException) => void = () => {} + + constructor( + name: string, + ctx: HttpContext, + manager: LimiterManager, + options?: LimiterConsumptionOptions + ) { + this.#name = name + this.#ctx = ctx + this.#manager = manager + this.#options = options + } + + /** + * Creates the key for the HTTP request + */ + protected createKey() { + return `${this.#name}_${this.#key || this.#ctx.request.ip()}` + } + + /** + * Specify the store you want to use during + * the request + */ + store(store: keyof KnownStores) { + this.#store = store + return this + } + + /** + * Specify the number of requests to allow + */ + allowRequests(requests: number) { + this.#options = this.#options || {} + this.#options.requests = requests + return this + } + + /** + * Specify the duration in seconds or a time expression + * for which the requests to allow. + * + * For example: allowRequests(10).every('1 minute') + */ + every(duration: number | string) { + this.#options = this.#options || {} + this.#options.duration = duration + return this + } + + /** + * Specify a custom unique key to identify the user. + * Defaults to: request.ip() + */ + usingKey(key: string | number) { + this.#key = key + return this + } + + /** + * Register a callback function to modify the ThrottleException. + */ + limitExceeded(callback: (error: ThrottleException) => void) { + this.#exceptionModifier = callback + return this + } + + /** + * JSON representation of the http limiter + */ + toJSON() { + return { + key: this.createKey(), + store: this.#store, + ...this.#options, + } + } + + /** + * Throttle request using the pre-defined options. Returns + * LimiterResponse when request is allowed or throws + * an exception. + */ + async throttle(): Promise { + if (!this.#options || !this.#options.requests || !this.#options.duration) { + throw new RuntimeException( + `Cannot throttle requests for "${this.#name}" limiter. Make sure to define the allowed requests and duration` + ) + } + + const limiter = this.#store + ? this.#manager.use(this.#store, this.#options as LimiterConsumptionOptions) + : this.#manager.use(this.#options as LimiterConsumptionOptions) + + const key = this.createKey() + debug('throttling HTTP request for key "%s"', key) + const limiterResponse = await limiter.get(key) + + /** + * Abort when user has exhausted all the requests + */ + if (limiterResponse && limiterResponse.remaining <= 0) { + debug('requests exhausted for key "%s"', key) + const error = new E_TOO_MANY_REQUESTS(limiterResponse) + this.#exceptionModifier(error) + throw error + } + + try { + const consumeResponse = await limiter.consume(key) + return consumeResponse + } catch (error) { + if (error instanceof E_TOO_MANY_REQUESTS) { + debug('requests exhausted for key "%s"', key) + this.#exceptionModifier(error) + } + throw error + } + } +} diff --git a/src/limiter_manager.ts b/src/limiter_manager.ts index ba7af0e..b4e952e 100644 --- a/src/limiter_manager.ts +++ b/src/limiter_manager.ts @@ -8,10 +8,12 @@ */ import string from '@adonisjs/core/helpers/string' +import type { HttpContext } from '@adonisjs/core/http' import { RuntimeException } from '@adonisjs/core/exceptions' import debug from './debug.js' import { Limiter } from './limiter.js' +import { HttpLimiter } from './http_limiter.js' import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js' /** @@ -105,4 +107,18 @@ export class LimiterManager) => HttpLimiter + ): (ctx: HttpContext) => HttpLimiter { + return (ctx: HttpContext) => { + const limiter = new HttpLimiter(name, ctx, this) + return builder(ctx, limiter) + } + } } diff --git a/src/middlewae/throttle_middleware.ts b/src/middlewae/throttle_middleware.ts new file mode 100644 index 0000000..20e3060 --- /dev/null +++ b/src/middlewae/throttle_middleware.ts @@ -0,0 +1,70 @@ +/* + * @adonisjs/limiter + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +import { HttpLimiter } from '../http_limiter.js' + +/** + * Throttle middleware used HTTP limiters to throttle incoming + * HTTP requests. + * + * The middleware defines the following rate limit headers as well + * + * During successful response + * - X-RateLimit-Limit + * - X-RateLimit-Remainin + * + * During error (via ThrottleException) + * - X-RateLimit-Limit + * - X-RateLimit-Remaining + * - Retry-After + * - X-RateLimit-Reset + * */ +export default class ThrottleMiddleware { + async handle( + ctx: HttpContext, + next: NextFn, + limiterFactory: ( + ctx: HttpContext + ) => HttpLimiter | null | Promise> | Promise + ) { + const limiter = await limiterFactory(ctx) + + /** + * Do not throttle when no limiter is used for + * the request + */ + if (!limiter) { + return next() + } + + /** + * Throttle request using the HTTP limiter + */ + const limiterResponse = await limiter.throttle() + + /** + * Invoke rest of the pipeline + */ + const response = await next() + + /** + * Define appropriate headers + */ + ctx.response.header('X-RateLimit-Limit', limiterResponse.limit) + ctx.response.header('X-RateLimit-Remaining', limiterResponse.remaining) + + /** + * Return response + */ + return response + } +} diff --git a/tests/http_limiter.spec.ts b/tests/http_limiter.spec.ts new file mode 100644 index 0000000..3c6bab1 --- /dev/null +++ b/tests/http_limiter.spec.ts @@ -0,0 +1,241 @@ +/* + * @adonisjs/limiter + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createRedis } from './helpers.js' +import { HttpLimiter } from '../src/http_limiter.js' +import LimiterRedisStore from '../src/stores/redis.js' +import { LimiterManager } from '../src/limiter_manager.js' + +test.group('Http limiter', () => { + test('define http limiter', async ({ assert }) => { + const redis = createRedis(['rlflx:ip_localhost']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(10).every('1 minute') + }) + + const ctx = new HttpContextFactory().create() + ctx.request.ip = function () { + return 'localhost' + } + + const limiter = apiLimiter(ctx) + + assert.instanceOf(limiter, HttpLimiter) + assert.deepEqual(limiter.toJSON(), { + duration: '1 minute', + key: `api_localhost`, + requests: 10, + store: undefined, + }) + }) + + test('define custom unique key', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(10).every('1 minute').usingKey(1) + }) + + const ctx = new HttpContextFactory().create() + const limiter = apiLimiter(ctx) + + assert.instanceOf(limiter, HttpLimiter) + assert.deepEqual(limiter.toJSON(), { + duration: '1 minute', + key: `api_1`, + requests: 10, + store: undefined, + }) + }) + + test('define named store', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(10).every('1 minute').usingKey(1).store('redis') + }) + + const ctx = new HttpContextFactory().create() + const limiter = apiLimiter(ctx) + + assert.instanceOf(limiter, HttpLimiter) + assert.deepEqual(limiter.toJSON(), { + duration: '1 minute', + key: `api_1`, + requests: 10, + store: 'redis', + }) + }) + + test('throttle requests', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(1).every('1 minute').usingKey(1) + }) + + const ctx = new HttpContextFactory().create() + const limiter = apiLimiter(ctx) + + await assert.doesNotReject(() => limiter.throttle()) + await assert.rejects(() => limiter.throttle()) + }) + + test('throttle requests using dynamic rules', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1', 'rlflx:api_2']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (ctx, limiter) => { + const userId = ctx.request.input('user_id') + return userId === 1 + ? limiter.allowRequests(1).every('1 minute').usingKey(userId) + : limiter.allowRequests(2).every('1 minute').usingKey(userId) + }) + + /** + * Allows one request for user with id 1 + */ + const ctx = new HttpContextFactory().create() + ctx.request.updateBody({ user_id: 1 }) + const limiter = apiLimiter(ctx) + await assert.doesNotReject(() => limiter.throttle()) + await assert.rejects(() => limiter.throttle()) + + /** + * Allows two requests for user with id 2 + */ + const freshCtx = new HttpContextFactory().create() + freshCtx.request.updateBody({ user_id: 2 }) + const freshLimiter = apiLimiter(freshCtx) + await assert.doesNotReject(() => freshLimiter.throttle()) + await assert.doesNotReject(() => freshLimiter.throttle()) + await assert.rejects(() => freshLimiter.throttle()) + }) + + test('customize exception', async ({ assert }) => { + assert.plan(2) + + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter + .allowRequests(1) + .every('1 minute') + .usingKey(1) + .limitExceeded((error) => { + error.setMessage('Requests exhaused').setStatus(400) + }) + }) + + const ctx = new HttpContextFactory().create() + const limiter = apiLimiter(ctx) + + await limiter.throttle() + try { + await limiter.throttle() + } catch (error) { + assert.equal(error.message, 'Requests exhaused') + assert.equal(error.status, 400) + } + }) + + test('throttle concurrent requests', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(1).every('1 minute').store('redis').usingKey(1) + }) + + const ctx = new HttpContextFactory().create() + const limiter = apiLimiter(ctx) + + const [first, second] = await Promise.allSettled([limiter.throttle(), limiter.throttle()]) + assert.equal(first.status, 'fulfilled') + assert.equal(second.status, 'rejected') + }) + + test('throw error when requests are not configured', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const noRequests = limiterManager.define('api', (_, limiter) => { + return limiter.every('1 minute').usingKey(1) + }) + const noDuration = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(100).usingKey(1) + }) + const noConfig = limiterManager.define('api', (_, limiter) => { + return limiter + }) + + const ctx = new HttpContextFactory().create() + await assert.rejects( + () => noRequests(ctx).throttle(), + 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration' + ) + await assert.rejects( + () => noDuration(ctx).throttle(), + 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration' + ) + await assert.rejects( + () => noConfig(ctx).throttle(), + 'Cannot throttle requests for "api" limiter. Make sure to define the allowed requests and duration' + ) + }) +}) diff --git a/tests/throttle_middleware.spec.ts b/tests/throttle_middleware.spec.ts new file mode 100644 index 0000000..7310d6d --- /dev/null +++ b/tests/throttle_middleware.spec.ts @@ -0,0 +1,92 @@ +/* + * @adonisjs/limiter + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { createRedis } from './helpers.js' +import LimiterRedisStore from '../src/stores/redis.js' +import { LimiterManager } from '../src/limiter_manager.js' +import ThrottleMiddleware from '../src/middlewae/throttle_middleware.js' + +test.group('Throttle middleware', () => { + test('throttle requests using the middleware', async ({ assert }) => { + let nextCalled: boolean = false + + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(1).every('1 minute').usingKey(1) + }) + + const ctx = new HttpContextFactory().create() + await new ThrottleMiddleware().handle( + ctx, + () => { + nextCalled = true + }, + apiLimiter + ) + + assert.equal(await limiterManager.use({ duration: 60, requests: 1 }).remaining('api_1'), 0) + assert.isTrue(nextCalled) + }) + + test('do not call next when key is blocked', async ({ assert }) => { + let nextCalled: boolean = false + + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', (_, limiter) => { + return limiter.allowRequests(1).every('1 minute').usingKey(1) + }) + const ctx = new HttpContextFactory().create() + + await apiLimiter(ctx).throttle() + + try { + await new ThrottleMiddleware().handle( + ctx, + () => { + nextCalled = true + }, + apiLimiter + ) + } catch (error) { + assert.equal(error.message, 'Too many requests') + assert.isFalse(nextCalled) + } + }) + + test('do not throttle request when no limiter is used', async ({ assert }) => { + let nextCalled: boolean = false + const ctx = new HttpContextFactory().create() + + await new ThrottleMiddleware().handle( + ctx, + () => { + nextCalled = true + }, + () => null + ) + assert.isTrue(nextCalled) + }) +})