From 53d5a14b9b6e40cd282c11d252fbd61c231cff4d Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Thu, 15 Oct 2026 11:05:38 +0530 Subject: [PATCH] feat: emit limit:exceeded event when a request is rejected The event is emitted every time a request is rejected because the key has exhausted its requests, across consume, attempt, penalize, multi limiters and HTTP throttling. Methods that only update the counters, like increment or set, do not emit the event. The provider passes the app emitter to the limiter manager, which shares it with the limiters it creates. The emitter is optional for standalone usage. --- providers/limiter_provider.ts | 6 ++- src/http_limiter.ts | 2 + src/limiter.ts | 36 +++++++++++-- src/limiter_manager.ts | 18 +++++-- src/multi_limiter.ts | 20 ++++++-- src/types.ts | 22 ++++++++ tests/helpers.ts | 23 +++++++++ tests/http_limiter.spec.ts | 33 +++++++++++- tests/limiter.spec.ts | 92 +++++++++++++++++++++++++++++++++- tests/limiter_provider.spec.ts | 4 +- tests/multi_limiter.spec.ts | 46 ++++++++++++++++- 11 files changed, 286 insertions(+), 16 deletions(-) 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 5eddfae..90a5567 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): Promise { - return this.#store.consume(key) + async consume(key: string | number): Promise { + try { + return await this.#store.consume(key) + } catch (error) { + if (error instanceof E_TOO_MANY_REQUESTS) { + this.#notifyExceeded({ key, response: error.response }) + } + throw error + } } /** @@ -106,6 +132,7 @@ export class Limiter implements LimiterStoreContract { */ const response = await this.get(key) if (response && response.consumed > response.limit) { + this.#notifyExceeded({ key, response }) return } @@ -152,6 +179,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 7794d8b..27e2a44 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 c5a6fc3..f528a80 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' @@ -316,4 +316,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'] + ) + }) })