Skip to content

Commit

Permalink
feat: add support for clearing rate limits
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Feb 5, 2024
1 parent 613ae20 commit 5979a71
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 18 deletions.
37 changes: 21 additions & 16 deletions src/stores/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { LimiterStoreContract } from '../types.js'
* must inherit your implementation from this class.
*/
export default abstract class RateLimiterBridge implements LimiterStoreContract {
#rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract
protected rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract

/**
* A unique name for the store
Expand All @@ -38,31 +38,36 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
* The number of configured requests on the store
*/
get requests() {
return this.#rateLimiter.points
return this.rateLimiter.points
}

/**
* The duration (in seconds) for which the requests are configured
*/
get duration() {
return this.#rateLimiter.duration
return this.rateLimiter.duration
}

constructor(rateLimiter: RateLimiterStoreAbstract | RateLimiterAbstract) {
this.#rateLimiter = rateLimiter
this.rateLimiter = rateLimiter
}

/**
* Clear database
*/
abstract clear(): Promise<void>

/**
* Consume 1 request for a given key. An exception is raised
* when all the requests have already been consumed or if
* the key is blocked.
*/
async consume(key: string | number): Promise<LimiterResponse> {
try {
const response = await this.#rateLimiter.consume(key, 1)
const response = await this.rateLimiter.consume(key, 1)
debug('request consumed for key %s', key)
return new LimiterResponse({
limit: this.#rateLimiter.points,
limit: this.rateLimiter.points,
remaining: response.remainingPoints,
consumed: response.consumedPoints,
availableIn: Math.ceil(response.msBeforeNext / 1000),
Expand All @@ -72,7 +77,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
if (errorResponse instanceof RateLimiterRes) {
throw new E_TOO_MANY_REQUESTS(
new LimiterResponse({
limit: this.#rateLimiter.points,
limit: this.rateLimiter.points,
remaining: errorResponse.remainingPoints,
consumed: errorResponse.consumedPoints,
availableIn: Math.ceil(errorResponse.msBeforeNext / 1000),
Expand All @@ -89,10 +94,10 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
* a value in seconds or a string expression.
*/
async block(key: string | number, duration: string | number): Promise<LimiterResponse> {
const response = await this.#rateLimiter.block(key, string.seconds.parse(duration))
const response = await this.rateLimiter.block(key, string.seconds.parse(duration))
debug('blocked key %s', key)
return new LimiterResponse({
limit: this.#rateLimiter.points,
limit: this.rateLimiter.points,
remaining: response.remainingPoints,
consumed: response.consumedPoints,
availableIn: Math.ceil(response.msBeforeNext / 1000),
Expand All @@ -114,7 +119,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
requests: number,
duration: string | number
): Promise<LimiterResponse> {
const response = await this.#rateLimiter.set(key, requests, string.seconds.parse(duration))
const response = await this.rateLimiter.set(key, requests, string.seconds.parse(duration))
debug('updated key %s with requests: %s, duration: %s', key, requests, duration)

/**
Expand All @@ -127,7 +132,7 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
const remaining = this.requests - response.consumedPoints

return new LimiterResponse({
limit: this.#rateLimiter.points,
limit: this.rateLimiter.points,
remaining: remaining < 0 ? 0 : remaining,
consumed: response.consumedPoints,
availableIn: Math.ceil(response.msBeforeNext / 1000),
Expand All @@ -139,15 +144,15 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
*/
delete(key: string | number): Promise<boolean> {
debug('deleting key %s', key)
return this.#rateLimiter.delete(key)
return this.rateLimiter.delete(key)
}

/**
* Delete all keys blocked within the memory
*/
deleteInMemoryBlockedKeys(): void {
if ('deleteInMemoryBlockedAll' in this.#rateLimiter) {
return this.#rateLimiter.deleteInMemoryBlockedAll()
if ('deleteInMemoryBlockedAll' in this.rateLimiter) {
return this.rateLimiter.deleteInMemoryBlockedAll()
}
}

Expand All @@ -156,14 +161,14 @@ export default abstract class RateLimiterBridge implements LimiterStoreContract
* key doesn't exist.
*/
async get(key: string | number): Promise<LimiterResponse | null> {
const response = await this.#rateLimiter.get(key)
const response = await this.rateLimiter.get(key)
debug('fetching key %s, %O', key, response)
if (!response || Number.isNaN(response.remainingPoints)) {
return null
}

return new LimiterResponse({
limit: this.#rateLimiter.points,
limit: this.rateLimiter.points,
remaining: response.remainingPoints,
consumed: response.consumedPoints,
availableIn: Math.ceil(response.msBeforeNext / 1000),
Expand Down
17 changes: 17 additions & 0 deletions src/stores/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import type { LimiterDatabaseStoreConfig } from '../types.js'
* implementations from the "rate-limiter-flixible" package.
*/
export default class LimiterDatabaseStore extends RateLimiterBridge {
#config: LimiterDatabaseStoreConfig
#client: QueryClientContract

get name() {
return 'database'
}
Expand Down Expand Up @@ -58,6 +61,8 @@ export default class LimiterDatabaseStore extends RateLimiterBridge {
: undefined,
})
)
this.#client = client
this.#config = config
break
case 'postgres':
super(
Expand All @@ -82,7 +87,19 @@ export default class LimiterDatabaseStore extends RateLimiterBridge {
: undefined,
})
)
this.#client = client
this.#config = config
break
}
}

/**
* Deletes all rows from the database table. Make sure to
* use separate database tables for every rate limiter
* your configure.
*/
async clear() {
debug('truncating database table %s', this.#config.tableName)
await this.#client.dialect.truncate(this.#config.tableName, true)
}
}
21 changes: 21 additions & 0 deletions src/stores/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type { LimiterMemoryStoreConfig } from '../types.js'
* from the "rate-limiter-flixible" package.
*/
export default class LimiterMemoryStore extends RateLimiterBridge {
#config: LimiterMemoryStoreConfig

get name() {
return 'memory'
}
Expand All @@ -36,5 +38,24 @@ export default class LimiterMemoryStore extends RateLimiterBridge {
: undefined,
})
)

this.#config = config
}

/**
* Clears the existing memory store to reset
* rate limits
*/
async clear() {
debug('clearing memory store')
this.rateLimiter = new RateLimiterMemory({
keyPrefix: this.#config.keyPrefix,
execEvenly: this.#config.execEvenly,
points: this.#config.requests,
duration: string.seconds.parse(this.#config.duration),
blockDuration: this.#config.blockDuration
? string.seconds.parse(this.#config.blockDuration)
: undefined,
})
}
}
24 changes: 23 additions & 1 deletion src/stores/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import string from '@adonisjs/core/helpers/string'
import { RateLimiterRedis } from 'rate-limiter-flexible'
import type { RedisClusterConnection, RedisConnection } from '@adonisjs/redis'
import { RedisClusterConnection, RedisConnection } from '@adonisjs/redis'

import debug from '../debug.js'
import RateLimiterBridge from './bridge.js'
Expand All @@ -20,6 +20,8 @@ import type { LimiterRedisStoreConfig } from '../types.js'
* from the "rate-limiter-flixible" package.
*/
export default class LimiterRedisStore extends RateLimiterBridge {
#client: RedisConnection | RedisClusterConnection

get name() {
return 'redis'
}
Expand All @@ -43,5 +45,25 @@ export default class LimiterRedisStore extends RateLimiterBridge {
: undefined,
})
)
this.#client = client
}

/**
* Flushes the redis database to clear existing
* rate limits.
*
* Make sure to use a separate db for store rate limits
* as this method flushes the entire database
*/
async clear() {
if (this.#client instanceof RedisClusterConnection) {
debug('flushing redis cluster')
for (let node of this.#client.nodes('master')) {
await node.flushdb()
}
} else {
debug('flushing redis database')
await this.#client.flushdb()
}
}
}
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export type LimiterDatabaseStoreConfig = LimiterStoreBaseConfig &
* The database table to use for storing keys. Defaults
* to "keyPrefix"
*/
tableName?: string
tableName: string

/**
* Define schema to use for making database queries.
Expand Down
21 changes: 21 additions & 0 deletions tests/stores/database.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,24 @@ test.group('Limiter database store | wrapper | delete', () => {
await assert.doesNotReject(() => store.consume('ip_localhost'))
})
})

test.group('Limiter database store | wrapper | clear', () => {
test('clear db', async ({ assert }) => {
const db = createDatabase()
await createTables(db)

const store = new LimiterDatabaseStore(db.connection(process.env.DB), {
dbName: 'limiter',
tableName: 'rate_limits',
duration: '1 minute',
requests: 5,
})

await store.consume('ip_localhost')
const response = await store.get('ip_localhost')
assert.instanceOf(response, LimiterResponse)

await store.clear()
assert.isNull(await store.get('ip_localhost'))
})
})
16 changes: 16 additions & 0 deletions tests/stores/memory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,19 @@ test.group('Limiter memory store | wrapper | delete', () => {
await assert.doesNotReject(() => store.consume('ip_localhost'))
})
})

test.group('Limiter memory store | wrapper | clear', () => {
test('clear db', async ({ assert }) => {
const store = new LimiterMemoryStore({
duration: '1 minute',
requests: 5,
})

await store.consume('ip_localhost')
const response = await store.get('ip_localhost')
assert.instanceOf(response, LimiterResponse)

await store.clear()
assert.isNull(await store.get('ip_localhost'))
})
})
17 changes: 17 additions & 0 deletions tests/stores/redis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,20 @@ test.group('Limiter redis store | wrapper | delete', () => {
await assert.doesNotReject(() => store.consume('ip_localhost'))
})
})

test.group('Limiter redis store | wrapper | clear', () => {
test('clear db', async ({ assert }) => {
const redis = createRedis(['rlflx:ip_localhost']).connection()
const store = new LimiterRedisStore(redis, {
duration: '1 minute',
requests: 5,
})

await store.consume('ip_localhost')
const response = await store.get('ip_localhost')
assert.instanceOf(response, LimiterResponse)

await store.clear()
assert.isNull(await store.get('ip_localhost'))
})
})

0 comments on commit 5979a71

Please sign in to comment.