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
76 changes: 72 additions & 4 deletions src/http_limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export class HttpLimiter<KnownStores extends Record<string, LimiterManagerStoreF
*/
#exceptionModifier: (error: ThrottleException) => void = () => {}

/**
* In dry run mode, requests are counted but never rejected
*/
#dryRun: boolean = false

constructor(manager: LimiterManager<KnownStores>, options?: LimiterConsumptionOptions) {
this.#manager = manager
this.#options = options || {}
Expand Down Expand Up @@ -126,6 +131,8 @@ export class HttpLimiter<KnownStores extends Record<string, LimiterManagerStoreF
* Registers a callback to customize the ThrottleException before it's thrown.
* Useful for setting custom error messages or translations.
*
* The callback is not called in dry run mode, since no exception is thrown.
*
* @param callback - Function to modify the exception
*
* @example
Expand Down Expand Up @@ -163,19 +170,56 @@ export class HttpLimiter<KnownStores extends Record<string, LimiterManagerStoreF
return this
}

/**
* Enables the dry run mode. Requests are counted, and the "limit:exceeded"
* event is emitted with "dryRun: true" when the limit is exceeded, but
* requests are never rejected. Useful to measure the impact of a rate
* limit on real traffic before enforcing it.
*
* Dry run counters use their own keys, so enforcing the limit later
* starts from fresh counters. Rate limit headers are not set in dry run
* mode, so clients cannot tell the limit exists.
*
* @param enabled - Enable or disable the dry run mode (default: true)
*
* @example
* ```ts
* limiter.allowRequests(100).every('1 hour').dryRun()
*
* emitter.on('limit:exceeded', ({ key, response, dryRun }) => {
* if (dryRun) {
* logger.warn({ key, consumed: response.consumed }, 'Request would have been throttled')
* }
* })
* ```
*/
dryRun(enabled: boolean = true): this {
this.#dryRun = enabled
return this
}

/**
* Returns true when the dry run mode is enabled
*/
isDryRun(): boolean {
return this.#dryRun
}

/**
* Returns a JSON representation of the HTTP limiter configuration.
*/
toJSON() {
return {
store: this.#store,
...this.#options,
...(this.#dryRun ? { dryRun: true } : {}),
}
}

/**
* Throttles the HTTP request using the configured options.
* Throws a ThrottleException if the rate limit is exceeded.
* Throws a ThrottleException if the rate limit is exceeded, unless
* the dry run mode is enabled.
*
* @param prefix - Key prefix to namespace the limiter
* @param ctx - HTTP context
Expand All @@ -197,7 +241,13 @@ export class HttpLimiter<KnownStores extends Record<string, LimiterManagerStoreF
? this.#manager.use(this.#store, this.#options as LimiterConsumptionOptions)
: this.#manager.use(this.#options as LimiterConsumptionOptions)

const key = `${prefix}_${this.#key || `ip_${ctx.request.ip()}`}`
/**
* Dry run keys are separated from the enforced ones. Otherwise, keys
* blocked during the dry run (using "blockFor") would be rejected
* as soon as the dry run mode is disabled.
*/
const namespace = this.#dryRun ? `${prefix}_dry` : prefix
const key = `${namespace}_${this.#key || `ip_${ctx.request.ip()}`}`
debug('throttling HTTP request for key "%s"', key)
const limiterResponse = await limiter.get(key)

Expand All @@ -210,19 +260,37 @@ 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 })
this.#manager.emitter?.emit('limit:exceeded', {
key,
response: limiterResponse,
dryRun: this.#dryRun,
})

if (this.#dryRun) {
debug('dry run, letting the request through for key "%s"', key)
return limiterResponse
}

const error = new E_TOO_MANY_REQUESTS(limiterResponse)
this.#exceptionModifier(error)
throw error
}

try {
const consumeResponse = await limiter.consume(key)
/**
* In dry run mode, the limiter emits the "limit:exceeded" event
* flagged as a dry run, and the rejection is turned into a
* response below
*/
const consumeResponse = await limiter.consume(key, undefined, { dryRun: this.#dryRun })
return consumeResponse
} catch (error) {
if (error instanceof E_TOO_MANY_REQUESTS) {
debug('requests exhausted for key "%s"', key)
if (this.#dryRun) {
debug('dry run, letting the request through for key "%s"', key)
return error.response
}
this.#exceptionModifier(error)
}
throw error
Expand Down
16 changes: 12 additions & 4 deletions src/limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,22 @@ export class Limiter implements LimiterStoreContract {
* Consumes one request for the given key. Throws a ThrottleException
* when the rate limit is exceeded or the key is blocked.
*
* The "dryRun" option only flags the "limit:exceeded" event. The
* exception is still thrown and it is up to the caller to let
* the request through.
*
* @param key - Unique identifier for the rate limit
*/
async consume(key: string | number, amount?: number): Promise<LimiterResponse> {
async consume(
key: string | number,
amount?: number,
options?: { dryRun?: boolean }
): 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 })
this.#notifyExceeded({ key, response: error.response, dryRun: options?.dryRun ?? false })
}
throw error
}
Expand Down Expand Up @@ -138,7 +146,7 @@ export class Limiter implements LimiterStoreContract {
*/
const response = await this.get(key)
if (response && response.consumed > response.limit) {
this.#notifyExceeded({ key, response })
this.#notifyExceeded({ key, response, dryRun: false })
return
}

Expand Down Expand Up @@ -186,7 +194,7 @@ export class Limiter implements LimiterStoreContract {
* Abort when user has exhausted all the requests
*/
if (response && response.remaining <= 0) {
this.#notifyExceeded({ key, response })
this.#notifyExceeded({ key, response, dryRun: false })
return [new E_TOO_MANY_REQUESTS(response), null]
}

Expand Down
9 changes: 6 additions & 3 deletions src/limiter_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,13 @@ export class LimiterManager<KnownStores extends Record<string, LimiterManagerSto
const response = await next()

/**
* Define appropriate headers
* Define appropriate headers. Not in dry run mode: clients should
* not be able to tell a limit exists before it is enforced
*/
ctx.response.header('X-RateLimit-Limit', limiterResponse.limit)
ctx.response.header('X-RateLimit-Remaining', limiterResponse.remaining)
if (!limiter.isDryRun()) {
ctx.response.header('X-RateLimit-Limit', limiterResponse.limit)
ctx.response.header('X-RateLimit-Remaining', limiterResponse.remaining)
}

/**
* Return response
Expand Down
1 change: 1 addition & 0 deletions src/multi_limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export class MultiLimiter {
this.#emitter?.emit('limit:exceeded', {
key: this.#limiters[exhaustedIndex].key,
response: exhaustedResponse,
dryRun: false,
})
return [new E_TOO_MANY_REQUESTS(exhaustedResponse), null]
}
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ export type LimiterExceededEvent = {
* The limiter response for the key at the time of the rejection
*/
response: LimiterResponse

/**
* True when the request was let through because the limiter runs
* in dry run mode. The request would have been rejected otherwise
*/
dryRun: boolean
}

/**
Expand Down
113 changes: 111 additions & 2 deletions tests/http_limiter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* file that was distributed with this source code.
*/

import sinon from 'sinon'
import { test } from '@japa/runner'
import { HttpContextFactory } from '@adonisjs/core/factories/http'

Expand Down Expand Up @@ -183,11 +184,119 @@ test.group('Http limiter', () => {
* before reaching the store. Both are reported
*/
assert.deepEqual(
events.all().map(({ data }) => data.key),
['api_1', 'api_1']
events.all().map(({ data }) => [data.key, data.dryRun]),
[
['api_1', false],
['api_1', false],
]
)
})

test('define dry run limiter', async ({ assert }) => {
const redis = createRedis().connection()
const limiterManager = new LimiterManager({
default: 'redis',
stores: {
redis: (options) => new LimiterRedisStore(redis, options),
},
})

const limiter = new HttpLimiter(limiterManager)
limiter.allowRequests(10).every('1 minute').dryRun()

assert.isTrue(limiter.isDryRun())
assert.deepEqual(limiter.toJSON(), {
duration: '1 minute',
requests: 10,
store: undefined,
dryRun: true,
})

limiter.dryRun(false)
assert.isFalse(limiter.isDryRun())
assert.deepEqual(limiter.toJSON(), {
duration: '1 minute',
requests: 10,
store: undefined,
})
})

test('do not reject requests in dry run mode', async ({ assert }) => {
const redis = createRedis(['rlflx:api_dry_1']).connection()
const { emitter, events } = createEmitter()
const limiterManager = new LimiterManager(
{
default: 'redis',
stores: {
redis: (options) => new LimiterRedisStore(redis, options),
},
},
emitter
)

const exceptionModifier = sinon.spy()
const ctx = new HttpContextFactory().create()
const limiter = new HttpLimiter(limiterManager)
limiter.allowRequests(1).every('1 minute').usingKey(1).dryRun().limitExceeded(exceptionModifier)

const first = await limiter.throttle('api', ctx)
const second = await limiter.throttle('api', ctx)
const third = await limiter.throttle('api', ctx)

assert.equal(first.remaining, 0)
assert.equal(second.remaining, 0)
assert.equal(third.remaining, 0)
assert.isFalse(exceptionModifier.called)

/**
* The second request is rejected by the store and the third one
* before reaching the store. Both are reported once, as dry runs
*/
assert.deepEqual(
events.all().map(({ data }) => [data.key, data.response.consumed, data.dryRun]),
[
['api_dry_1', 2, true],
['api_dry_1', 2, true],
]
)
})

test('use separate keys in dry run mode', async ({ assert }) => {
const redis = createRedis(['rlflx:api_1', 'rlflx:api_dry_1']).connection()
const limiterManager = new LimiterManager({
default: 'redis',
stores: {
redis: (options) => new LimiterRedisStore(redis, options),
},
})

const ctx = new HttpContextFactory().create()
const dryRunLimiter = new HttpLimiter(limiterManager)
dryRunLimiter.allowRequests(1).every('1 minute').blockFor('30 mins').usingKey(1).dryRun()

await dryRunLimiter.throttle('api', ctx)
await dryRunLimiter.throttle('api', ctx)
await dryRunLimiter.throttle('api', ctx)

/**
* The key observed during the dry run is blocked in the dry
* run namespace only
*/
const store = limiterManager.use({
requests: 1,
duration: '1 minute',
blockDuration: '30 mins',
})
assert.isBelow(await store.remaining('api_dry_1'), 1)
assert.isAbove(await store.availableIn('api_dry_1'), 60)

const limiter = new HttpLimiter(limiterManager)
limiter.allowRequests(1).every('1 minute').blockFor('30 mins').usingKey(1)

await assert.doesNotReject(() => limiter.throttle('api', ctx))
await assert.rejects(() => limiter.throttle('api', ctx))
})

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