diff --git a/providers/limiter_provider.ts b/providers/limiter_provider.ts index 1852046..38980ed 100644 --- a/providers/limiter_provider.ts +++ b/providers/limiter_provider.ts @@ -12,12 +12,13 @@ import { type ApplicationService } from '@adonisjs/core/types' import { RuntimeException } from '@adonisjs/core/exceptions' import { LimiterManager } from '../index.ts' -import type { LimiterService } from '../src/types.ts' +import type { LimiterEvents, LimiterService } from '../src/types.ts' declare module '@adonisjs/core/types' { export interface ContainerBindings { 'limiter.manager': LimiterService } + export interface EventsList extends LimiterEvents {} } export default class LimiterProvider { @@ -37,7 +38,8 @@ export default class LimiterProvider { ) } - return new LimiterManager(config) + const emitter = await this.app.container.make('emitter') + return new LimiterManager(config, emitter) }) } } diff --git a/src/http_limiter.ts b/src/http_limiter.ts index fc30daf..7f44bb7 100644 --- a/src/http_limiter.ts +++ b/src/http_limiter.ts @@ -210,6 +210,8 @@ export class HttpLimiter limiterResponse.limit) { debug('requests exhausted for key "%s"', key) + this.#manager.emitter?.emit('limit:exceeded', { key, response: limiterResponse }) + const error = new E_TOO_MANY_REQUESTS(limiterResponse) this.#exceptionModifier(error) throw error diff --git a/src/limiter.ts b/src/limiter.ts index 3ea2850..6bb59f3 100644 --- a/src/limiter.ts +++ b/src/limiter.ts @@ -7,17 +7,28 @@ * file that was distributed with this source code. */ +import type { Emitter } from '@adonisjs/core/events' + import { type LimiterResponse } from './response.ts' -import type { LimiterStoreContract } from './types.ts' import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.ts' +import type { LimiterExceededEvent, LimiterStoreContract } from './types.ts' /** * Limiter provides a high-level API for rate limiting operations. * It wraps limiter stores and adds convenience methods like attempt() and penalize(). + * + * When an emitter is provided, every rejected request is reported using the + * "limit:exceeded" event. Methods that only change the counters, like + * increment() or set(), never emit the event. */ export class Limiter implements LimiterStoreContract { #store: LimiterStoreContract + /** + * Optional emitter to notify about the rejected requests + */ + #emitter?: Emitter + get name() { return this.#store.name } @@ -43,8 +54,16 @@ export class Limiter implements LimiterStoreContract { return this.#store.blockDuration } - constructor(store: LimiterStoreContract) { + constructor(store: LimiterStoreContract, emitter?: Emitter) { this.#store = store + this.#emitter = emitter + } + + /** + * Emits the "limit:exceeded" event for a rejected request + */ + #notifyExceeded(data: LimiterExceededEvent) { + this.#emitter?.emit('limit:exceeded', data) } /** @@ -53,8 +72,15 @@ export class Limiter implements LimiterStoreContract { * * @param key - Unique identifier for the rate limit */ - consume(key: string | number, amount?: number): Promise { - return this.#store.consume(key, amount) + async consume(key: string | number, amount?: number): Promise { + try { + return await this.#store.consume(key, amount) + } catch (error) { + if (error instanceof E_TOO_MANY_REQUESTS) { + this.#notifyExceeded({ key, response: error.response }) + } + throw error + } } /** @@ -112,6 +138,7 @@ export class Limiter implements LimiterStoreContract { */ const response = await this.get(key) if (response && response.consumed > response.limit) { + this.#notifyExceeded({ key, response }) return } @@ -159,6 +186,7 @@ export class Limiter implements LimiterStoreContract { * Abort when user has exhausted all the requests */ if (response && response.remaining <= 0) { + this.#notifyExceeded({ key, response }) return [new E_TOO_MANY_REQUESTS(response), null] } diff --git a/src/limiter_manager.ts b/src/limiter_manager.ts index c757044..e029763 100644 --- a/src/limiter_manager.ts +++ b/src/limiter_manager.ts @@ -8,6 +8,7 @@ */ import string from '@adonisjs/core/helpers/string' +import type { Emitter } from '@adonisjs/core/events' import type { HttpContext } from '@adonisjs/core/http' import type { MiddlewareFn } from '@adonisjs/core/types/http' import { RuntimeException } from '@adonisjs/core/exceptions' @@ -32,8 +33,18 @@ export class LimiterManager> = new Map() - constructor(public config: { default: keyof KnownStores; stores: KnownStores }) { + /** + * Optional emitter to notify about the rejected requests using + * the "limit:exceeded" event + */ + emitter?: Emitter + + constructor( + public config: { default: keyof KnownStores; stores: KnownStores }, + emitter?: Emitter + ) { this.config = config + this.emitter = emitter } /** @@ -105,7 +116,8 @@ export class LimiterManager + + constructor(limiters: { key: string | number; limiter: Limiter }[], emitter?: Emitter) { this.#limiters = limiters + this.#emitter = emitter } /** @@ -149,12 +158,17 @@ export class MultiLimiter { callback: () => T | Promise ): Promise<[null, T] | [ThrottleException, null]> { const responses = await this.get() - const exhaustedResponse = responses.find((response) => response && response.remaining <= 0) + const exhaustedIndex = responses.findIndex((response) => response && response.remaining <= 0) /** * Abort when user has exhausted all the requests */ - if (exhaustedResponse) { + if (exhaustedIndex !== -1) { + const exhaustedResponse = responses[exhaustedIndex]! + this.#emitter?.emit('limit:exceeded', { + key: this.#limiters[exhaustedIndex].key, + response: exhaustedResponse, + }) return [new E_TOO_MANY_REQUESTS(exhaustedResponse), null] } diff --git a/src/types.ts b/src/types.ts index ccadb36..017babe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -267,3 +267,25 @@ export type InferLimiters< export interface LimiterService extends LimiterManager< LimitersList extends Record ? LimitersList : never > {} + +/** + * Data shared with the "limit:exceeded" event + */ +export type LimiterExceededEvent = { + /** + * The key that has exceeded the limit + */ + key: string | number + + /** + * The limiter response for the key at the time of the rejection + */ + response: LimiterResponse +} + +/** + * Events emitted by the limiter + */ +export type LimiterEvents = { + 'limit:exceeded': LimiterExceededEvent +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 19fd1ce..0fcd03a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -16,6 +16,8 @@ import { AppFactory } from '@adonisjs/core/factories/app' import { type RedisConnection, RedisManager } from '@adonisjs/redis' import { LoggerFactory } from '@adonisjs/core/factories/logger' +import type { LimiterEvents } from '../src/types.ts' + configDotenv() declare module '@adonisjs/redis/types' { @@ -82,6 +84,27 @@ export function createDatabase() { return db } +/** + * Creates an emitter instance with faked events, so that the + * emitted events can be asserted + */ +export function createEmitter(): { + emitter: Emitter + events: ReturnType['fake']> +} { + const test = getActiveTest() + if (!test) { + throw new Error('Cannot use "createEmitter" outside of a Japa test') + } + + const app = new AppFactory().create(test.context.fs.baseUrl, () => {}) + const emitter = new Emitter(app) + const events = emitter.fake() + + test.cleanup(() => emitter.restore()) + return { emitter, events } +} + /** * Creates redis manager instance to execute redis * commands diff --git a/tests/http_limiter.spec.ts b/tests/http_limiter.spec.ts index 8986b60..af89fb4 100644 --- a/tests/http_limiter.spec.ts +++ b/tests/http_limiter.spec.ts @@ -10,7 +10,7 @@ import { test } from '@japa/runner' import { HttpContextFactory } from '@adonisjs/core/factories/http' -import { createRedis } from './helpers.ts' +import { createEmitter, createRedis } from './helpers.ts' import { HttpLimiter } from '../src/http_limiter.ts' import LimiterRedisStore from '../src/stores/redis.ts' import { LimiterManager } from '../src/limiter_manager.ts' @@ -157,6 +157,37 @@ test.group('Http limiter', () => { assert.equal(second.status, 'rejected') }) + test('emit limit:exceeded event for the throttled key', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const { emitter, events } = createEmitter() + const limiterManager = new LimiterManager( + { + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }, + emitter + ) + + const ctx = new HttpContextFactory().create() + const limiter = new HttpLimiter(limiterManager) + limiter.allowRequests(1).every('1 minute').usingKey(1) + + await limiter.throttle('api', ctx) + await assert.rejects(() => limiter.throttle('api', ctx)) + await assert.rejects(() => limiter.throttle('api', ctx)) + + /** + * The second request is rejected by the store and the third one + * before reaching the store. Both are reported + */ + assert.deepEqual( + events.all().map(({ data }) => data.key), + ['api_1', 'api_1'] + ) + }) + test('throw error when requests are not configured', async ({ assert }) => { const redis = createRedis(['rlflx:api_1']).connection() const limiterManager = new LimiterManager({ diff --git a/tests/limiter.spec.ts b/tests/limiter.spec.ts index ce8efe0..bdfad7f 100644 --- a/tests/limiter.spec.ts +++ b/tests/limiter.spec.ts @@ -10,7 +10,7 @@ import sinon from 'sinon' import { test } from '@japa/runner' -import { createRedis } from './helpers.ts' +import { createEmitter, createRedis } from './helpers.ts' import { Limiter } from '../src/limiter.ts' import LimiterRedisStore from '../src/stores/redis.ts' import { ThrottleException } from '../src/errors.ts' @@ -458,4 +458,94 @@ test.group('Limiter', () => { assert.closeTo(await limiter.availableIn('ip_localhost'), 60 * 30, 5) }) + + test('emit limit:exceeded event when consume is rejected', async ({ assert }) => { + const redis = createRedis(['rlflx:ip_localhost']).connection() + const store = new LimiterRedisStore(redis, { + duration: '1 minute', + requests: 1, + }) + + const { emitter, events } = createEmitter() + const limiter = new Limiter(store, emitter) + + await limiter.consume('ip_localhost') + assert.equal(events.size(), 0) + + await assert.rejects(() => limiter.consume('ip_localhost')) + await assert.rejects(() => limiter.consume('ip_localhost')) + + assert.deepEqual( + events.all().map(({ event, data }) => [event, data.key, data.response.consumed]), + [ + ['limit:exceeded', 'ip_localhost', 2], + ['limit:exceeded', 'ip_localhost', 3], + ] + ) + }) + + test('emit limit:exceeded event for every rejected attempt', async () => { + const redis = createRedis(['rlflx:ip_localhost']).connection() + const store = new LimiterRedisStore(redis, { + duration: '1 minute', + requests: 1, + }) + + const { emitter, events } = createEmitter() + const limiter = new Limiter(store, emitter) + + await limiter.attempt('ip_localhost', () => {}) + await limiter.attempt('ip_localhost', () => {}) + await limiter.attempt('ip_localhost', () => {}) + + /** + * The second attempt is rejected by the store and the third one + * before reaching the store. Both are reported + */ + events.assertEmittedCount('limit:exceeded', 2) + }) + + test('emit limit:exceeded event when penalize is rejected', async ({ assert }) => { + const redis = createRedis(['rlflx:ip_localhost']).connection() + const store = new LimiterRedisStore(redis, { + duration: '1 minute', + requests: 1, + }) + + const { emitter, events } = createEmitter() + const limiter = new Limiter(store, emitter) + + /** + * Exhausting the requests is not a rejection + */ + await assert.rejects(async () => { + await limiter.penalize('ip_localhost', () => { + throw new Error('Something went wrong') + }) + }, 'Something went wrong') + assert.equal(events.size(), 0) + + const [error] = await limiter.penalize('ip_localhost', () => {}) + assert.instanceOf(error, ThrottleException) + events.assertEmitted('limit:exceeded', ({ data }) => { + return data.key === 'ip_localhost' && data.response.remaining === 0 + }) + }) + + test('do not emit limit:exceeded event when incrementing past the limit', async ({ assert }) => { + const redis = createRedis(['rlflx:ip_localhost']).connection() + const store = new LimiterRedisStore(redis, { + duration: '1 minute', + requests: 1, + }) + + const { emitter, events } = createEmitter() + const limiter = new Limiter(store, emitter) + + await limiter.increment('ip_localhost') + await limiter.increment('ip_localhost') + await limiter.set('ip_localhost', 5) + + assert.equal(events.size(), 0) + }) }) diff --git a/tests/limiter_provider.spec.ts b/tests/limiter_provider.spec.ts index a27da34..dae540c 100644 --- a/tests/limiter_provider.spec.ts +++ b/tests/limiter_provider.spec.ts @@ -55,7 +55,9 @@ test.group('Limiter provider', () => { app.container.singleton('redis', () => redis) await app.boot() - assert.instanceOf(await app.container.make('limiter.manager'), LimiterManager) + const limiter = await app.container.make('limiter.manager') + assert.instanceOf(limiter, LimiterManager) + assert.strictEqual(limiter.emitter, await app.container.make('emitter')) }) test('throw error when config is invalid', async () => { diff --git a/tests/multi_limiter.spec.ts b/tests/multi_limiter.spec.ts index ed0321b..468490c 100644 --- a/tests/multi_limiter.spec.ts +++ b/tests/multi_limiter.spec.ts @@ -9,7 +9,7 @@ import { test } from '@japa/runner' -import { createRedis } from './helpers.ts' +import { createEmitter, createRedis } from './helpers.ts' import { Limiter } from '../src/limiter.ts' import { ThrottleException } from '../src/errors.ts' import LimiterRedisStore from '../src/stores/redis.ts' @@ -283,4 +283,48 @@ test.group('MultiLimiter', () => { assert.equal(await limiter.list()[1].limiter.remaining('email:foo@bar.com'), 3) assert.closeTo(await limiter.list()[0].limiter.availableIn('ip_localhost'), 60 * 30, 5) }) + + test('emit limit:exceeded event for the rejected limiter', async ({ assert }) => { + const redis = createRedis(['rlflx:ip_localhost', 'rlflx:email:foo@bar.com']).connection() + const { emitter, events } = createEmitter() + + const limiter = new MultiLimiter( + [ + { + key: 'ip_localhost', + limiter: new Limiter( + new LimiterRedisStore(redis, { duration: '1 minute', requests: 2 }), + emitter + ), + }, + { + key: 'email:foo@bar.com', + limiter: new Limiter( + new LimiterRedisStore(redis, { duration: '1 minute', requests: 1 }), + emitter + ), + }, + ], + emitter + ) + + await assert.rejects(async () => { + await limiter.penalize(() => { + throw new Error('Something went wrong') + }) + }, 'Something went wrong') + assert.equal(events.size(), 0) + + const [error] = await limiter.penalize(() => {}) + assert.instanceOf(error, ThrottleException) + + await limiter.delete() + await limiter.consume() + await assert.rejects(() => limiter.consume()) + + assert.deepEqual( + events.all().map(({ data }) => data.key), + ['email:foo@bar.com', 'email:foo@bar.com'] + ) + }) })