Skip to content

Commit

Permalink
feat: add limiter manager
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Feb 1, 2024
1 parent 48f1c8c commit e179cf3
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 1 deletion.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"typescript": "^5.1.6"
},
"dependencies": {
"@poppinss/utils": "^6.7.1",
"rate-limiter-flexible": "^4.0.1"
},
"peerDependencies": {
Expand Down
12 changes: 12 additions & 0 deletions src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* @adonisjs/limiter
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { debuglog } from 'node:util'

export default debuglog('adonisjs:limiter')
108 changes: 108 additions & 0 deletions src/limiter_manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* @adonisjs/limiter
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import string from '@adonisjs/core/helpers/string'
import { RuntimeException } from '@adonisjs/core/exceptions'

import debug from './debug.js'
import { Limiter } from './limiter.js'
import type { LimiterConsumptionOptions, LimiterManagerStoreFactory } from './types.js'

/**
* Limiter manager is used to manage multiple rate limiters
* using different storage providers.
*
* Also, you can create limiter instances with runtime options
* for "requests", "duration", and "blockDuration".
*/
export class LimiterManager<KnownStores extends Record<string, LimiterManagerStoreFactory>> {
/**
* Configuration with a collection of known stores
*/
#config: { default: keyof KnownStores; stores: KnownStores }

/**
* Cached limiters. One limiter is created for a unique combination
* of "store,requests,duration,blockDuration" options
*/
#limiters: Map<string, Limiter> = new Map()

constructor(config: { default: keyof KnownStores; stores: KnownStores }) {
this.#config = config
}

/**
* Creates a unique key for a limiter instance. Since, we allow creating
* limiters with runtime options for "requests", "duration" and "blockDuration".
* The limiterKey is used to identify a limiter instance.
*/
protected makeLimiterKey(store: keyof KnownStores, options: LimiterConsumptionOptions) {
const chunks = [`s:${String(store)}`, `r:${options.requests}`, `d:${options.duration}`]
if (options.blockDuration) {
chunks.push(`bd:${options.blockDuration}`)
}
return chunks.join(',')
}

/**
* Make a limiter instance for a given store and with
* runtime options.
*
* Caches instances forever for the lifecycle of the process.
*/
use(options: LimiterConsumptionOptions): Limiter
use<K extends keyof KnownStores>(store: K, options: LimiterConsumptionOptions): Limiter
use(
store: keyof KnownStores | LimiterConsumptionOptions,
options?: LimiterConsumptionOptions
): Limiter {
/**
* Normalize options
*/
let storeToUse: keyof KnownStores = typeof store === 'string' ? store : this.#config.default
let optionsToUse: LimiterConsumptionOptions | undefined =
typeof store === 'object' ? store : options

/**
* Ensure options are defined
*/
if (!optionsToUse) {
throw new RuntimeException(
'Specify the number of allowed requests and duration to create a limiter'
)
}

optionsToUse.duration = string.seconds.parse(optionsToUse.duration)
if (optionsToUse.blockDuration) {
optionsToUse.blockDuration = string.seconds.parse(optionsToUse.blockDuration)
}

/**
* Make limiter key to uniquely identify a limiter
*/
const limiterKey = this.makeLimiterKey(storeToUse, optionsToUse)
debug('created limiter key "%s"', limiterKey)

/**
* Read and return from cache
*/
if (this.#limiters.has(limiterKey)) {
debug('re-using cached limiter store "%s", options %O', storeToUse, optionsToUse)
return this.#limiters.get(limiterKey)!
}

/**
* Create a fresh instance and cache it
*/
const limiter = new Limiter(this.#config.stores[storeToUse](optionsToUse))
debug('creating new limiter instance "%s", options %O', storeToUse, optionsToUse)
this.#limiters.set(limiterKey, limiter)
return limiter
}
}
7 changes: 7 additions & 0 deletions src/stores/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { LimiterResponse } from '../response.js'
import { E_TOO_MANY_REQUESTS } from '../errors.js'
import type { LimiterStoreContract } from '../types.js'
import debug from '../debug.js'

/**
* The bridget store acts as a bridge between the "rate-limiter-flexible"
Expand Down Expand Up @@ -59,13 +60,15 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
async consume(key: string | number): Promise<LimiterResponse> {
try {
const response = await this.#rateLimiter.consume(key, 1)
debug('request consumed for key %s', key)
return new LimiterResponse({
limit: this.#rateLimiter.points,
remaining: response.remainingPoints,
consumed: response.consumedPoints,
availableIn: Math.ceil(response.msBeforeNext / 1000),
})
} catch (errorResponse: unknown) {
debug('unable to consume request for key %s, %O', key, errorResponse)
if (errorResponse instanceof RateLimiterRes) {
throw new E_TOO_MANY_REQUESTS(
new LimiterResponse({
Expand All @@ -87,6 +90,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
*/
async block(key: string | number, duration: string | number): Promise<LimiterResponse> {
const response = await this.#rateLimiter.block(key, string.seconds.parse(duration))
debug('blocked key %s', key)
return new LimiterResponse({
limit: this.#rateLimiter.points,
remaining: response.remainingPoints,
Expand All @@ -111,6 +115,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
duration: string | number
): Promise<LimiterResponse> {
const response = await this.#rateLimiter.set(key, requests, string.seconds.parse(duration))
debug('updated key %s with requests: %s, duration: %s', key, requests, duration)

/**
* The value of "response.remainingPoints" in a set method call
Expand All @@ -133,6 +138,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
* Delete a given key
*/
delete(key: string | number): Promise<boolean> {
debug('deleting key %s', key)
return this.#rateLimiter.delete(key)
}

Expand All @@ -151,6 +157,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
*/
async get(key: string | number): Promise<LimiterResponse | null> {
const response = await this.#rateLimiter.get(key)
debug('fetching key %s, %O', key, response)
if (!response || Number.isNaN(response.remainingPoints)) {
return null
}
Expand Down
3 changes: 3 additions & 0 deletions src/stores/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RuntimeException } from '@poppinss/utils'
import type { QueryClientContract } from '@adonisjs/lucid/types/database'
import { RateLimiterMySQL, RateLimiterPostgres } from 'rate-limiter-flexible'

import debug from '../debug.js'
import RateLimiterBridge from './bridge.js'
import type { LimiterDatabaseStoreConfig } from '../types.js'

Expand All @@ -32,6 +33,8 @@ export default class LimiterDatabaseStore extends RateLimiterBridge {
)
}

debug('creating %s limiter store %O', dialectName, config)

switch (dialectName) {
case 'mysql':
super(
Expand Down
2 changes: 2 additions & 0 deletions src/stores/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import string from '@adonisjs/core/helpers/string'
import { RateLimiterMemory } from 'rate-limiter-flexible'

import debug from '../debug.js'
import RateLimiterBridge from './bridge.js'
import type { LimiterMemoryStoreConfig } from '../types.js'

Expand All @@ -23,6 +24,7 @@ export default class LimiterMemoryStore extends RateLimiterBridge {
}

constructor(config: LimiterMemoryStoreConfig) {
debug('creating memory limiter store %O', config)
super(
new RateLimiterMemory({
keyPrefix: config.keyPrefix,
Expand Down
2 changes: 2 additions & 0 deletions src/stores/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import string from '@adonisjs/core/helpers/string'
import type { RedisConnection } from '@adonisjs/redis'
import { RateLimiterRedis } from 'rate-limiter-flexible'

import debug from '../debug.js'
import RateLimiterBridge from './bridge.js'
import type { LimiterRedisStoreConfig } from '../types.js'

Expand All @@ -24,6 +25,7 @@ export default class LimiterRedisStore extends RateLimiterBridge {
}

constructor(client: RedisConnection, config: LimiterRedisStoreConfig) {
debug('creating redis limiter store %O', config)
super(
new RateLimiterRedis({
rejectIfRedisNotReady: config.rejectIfRedisNotReady,
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,11 @@ export interface LimiterStoreContract {
*/
get(key: string | number): Promise<LimiterResponse | null>
}

/**
* The manager factory is used to create an instance of the
* store with consumption options
*/
export type LimiterManagerStoreFactory = (
options: LimiterConsumptionOptions
) => LimiterStoreContract
90 changes: 90 additions & 0 deletions tests/limiter_manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* @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 { createRedis } from './helpers.js'
import { Limiter } from '../src/limiter.js'
import LimiterRedisStore from '../src/stores/redis.js'
import { LimiterManager } from '../src/limiter_manager.js'

test.group('Limiter manager', () => {
test('create limiter instances using manager', async ({ assert }) => {
const redis = createRedis(['rlflx:ip_localhost']).connection()
const limiterManager = new LimiterManager({
default: 'redis',
stores: {
redis: (options) => new LimiterRedisStore(redis, options),
},
})

const limiter = limiterManager.use('redis', { requests: 10, duration: '2 minutes' })
assert.instanceOf(limiter, Limiter)

const response = await limiter.consume('ip_localhost')
assert.containsSubset(response.toJSON(), {
limit: 10,
remaining: 9,
consumed: 1,
})
assert.closeTo(response.availableIn, 120, 5)
})

test('re-use instances as long as all options are the same', async ({ assert }) => {
const redis = createRedis(['rlflx:ip_localhost']).connection()
const limiterManager = new LimiterManager({
default: 'redis',
stores: {
redis: (options) => new LimiterRedisStore(redis, options),
},
})

assert.strictEqual(
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
limiterManager.use('redis', { requests: 10, duration: '2 minutes' })
)
assert.strictEqual(
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
limiterManager.use({ requests: 10, duration: '2 minutes' })
)
assert.notStrictEqual(
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
limiterManager.use('redis', { requests: 10, duration: '1 minute' })
)
assert.notStrictEqual(
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
limiterManager.use('redis', { requests: 5, duration: '2 minutes' })
)
assert.notStrictEqual(
limiterManager.use('redis', { requests: 10, duration: '2 minutes' }),
limiterManager.use('redis', { requests: 10, duration: '2 minutes', blockDuration: '2 mins' })
)
})

test('throw error when no options are provided', async ({ assert }) => {
const redis = createRedis(['rlflx:ip_localhost']).connection()
const limiterManager = new LimiterManager({
default: 'redis',
stores: {
redis: (options) => new LimiterRedisStore(redis, options),
},
})

assert.throws(
// @ts-expect-error
() => limiterManager.use('redis'),
'Specify the number of allowed requests and duration to create a limiter'
)
assert.throws(
// @ts-expect-error
() => limiterManager.use(),
'Specify the number of allowed requests and duration to create a limiter'
)
})
})

0 comments on commit e179cf3

Please sign in to comment.