Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions providers/limiter_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
})
}
}
2 changes: 2 additions & 0 deletions src/http_limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export class HttpLimiter<KnownStores extends Record<string, LimiterManagerStoreF
*/
if (limiterResponse && limiterResponse.consumed > 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
Expand Down
36 changes: 32 additions & 4 deletions src/limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>

get name() {
return this.#store.name
}
Expand All @@ -43,8 +54,16 @@ export class Limiter implements LimiterStoreContract {
return this.#store.blockDuration
}

constructor(store: LimiterStoreContract) {
constructor(store: LimiterStoreContract, emitter?: Emitter<any>) {
this.#store = store
this.#emitter = emitter
}

/**
* Emits the "limit:exceeded" event for a rejected request
*/
#notifyExceeded(data: LimiterExceededEvent) {
this.#emitter?.emit('limit:exceeded', data)
}

/**
Expand All @@ -53,8 +72,15 @@ export class Limiter implements LimiterStoreContract {
*
* @param key - Unique identifier for the rate limit
*/
consume(key: string | number, amount?: number): Promise<LimiterResponse> {
return this.#store.consume(key, amount)
async consume(key: string | number, amount?: number): Promise<LimiterResponse> {
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
}
}

/**
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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]
}

Expand Down
18 changes: 15 additions & 3 deletions src/limiter_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -32,8 +33,18 @@ export class LimiterManager<KnownStores extends Record<string, LimiterManagerSto
*/
#limiters: Map<string, Map<string, Limiter>> = 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<any>

constructor(
public config: { default: keyof KnownStores; stores: KnownStores },
emitter?: Emitter<any>
) {
this.config = config
this.emitter = emitter
}

/**
Expand Down Expand Up @@ -105,7 +116,8 @@ export class LimiterManager<KnownStores extends Record<string, LimiterManagerSto
key: limiterOptions.key,
limiter: this.use(storeToUse, limiterOptions),
}
})
}),
this.emitter
)
}

Expand Down Expand Up @@ -188,7 +200,7 @@ export class LimiterManager<KnownStores extends Record<string, LimiterManagerSto
/**
* Create a fresh instance and cache it
*/
const limiter = new Limiter(this.config.stores[storeToUse](optionsToUse))
const limiter = new Limiter(this.config.stores[storeToUse](optionsToUse), this.emitter)
debug('creating new limiter instance "%s", options %O', storeToUse, optionsToUse)
storeLimiters.set(limiterKey, limiter)
return limiter
Expand Down
20 changes: 17 additions & 3 deletions src/multi_limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* file that was distributed with this source code.
*/

import type { Emitter } from '@adonisjs/core/events'

import { type Limiter } from './limiter.ts'
import { type LimiterResponse } from './response.ts'
import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.ts'
Expand All @@ -18,8 +20,15 @@ import { E_TOO_MANY_REQUESTS, type ThrottleException } from './errors.ts'
*/
export class MultiLimiter {
#limiters: { key: string | number; limiter: Limiter }[]
constructor(limiters: { key: string | number; limiter: Limiter }[]) {

/**
* Optional emitter to notify about the rejected requests
*/
#emitter?: Emitter<any>

constructor(limiters: { key: string | number; limiter: Limiter }[], emitter?: Emitter<any>) {
this.#limiters = limiters
this.#emitter = emitter
}

/**
Expand Down Expand Up @@ -149,12 +158,17 @@ export class MultiLimiter {
callback: () => T | Promise<T>
): 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]
}

Expand Down
22 changes: 22 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,25 @@ export type InferLimiters<
export interface LimiterService extends LimiterManager<
LimitersList extends Record<string, LimiterManagerStoreFactory> ? 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
}
23 changes: 23 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down Expand Up @@ -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<LimiterEvents>
events: ReturnType<Emitter<LimiterEvents>['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<LimiterEvents>(app)
const events = emitter.fake()

test.cleanup(() => emitter.restore())
return { emitter, events }
}

/**
* Creates redis manager instance to execute redis
* commands
Expand Down
33 changes: 32 additions & 1 deletion tests/http_limiter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading