From 8560a10ef9b20752bd9a915f1a8ecd1ae8055c57 Mon Sep 17 00:00:00 2001 From: Maxime <57860498+MaximeMRF@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:49:08 +0300 Subject: [PATCH] feat: add dry run mode to HTTP limiters Requests are counted on separate keys but never rejected. The limit:exceeded event is emitted with dryRun: true instead of calling limitExceeded, and no rate limit headers are set. --- src/http_limiter.ts | 76 ++++++++++++++++++-- src/limiter.ts | 16 +++-- src/limiter_manager.ts | 9 ++- src/multi_limiter.ts | 1 + src/types.ts | 6 ++ tests/http_limiter.spec.ts | 113 +++++++++++++++++++++++++++++- tests/throttle_middleware.spec.ts | 62 +++++++++++++++- 7 files changed, 269 insertions(+), 14 deletions(-) diff --git a/src/http_limiter.ts b/src/http_limiter.ts index 7f44bb7..2a4aa80 100644 --- a/src/http_limiter.ts +++ b/src/http_limiter.ts @@ -49,6 +49,11 @@ export class HttpLimiter void = () => {} + /** + * In dry run mode, requests are counted but never rejected + */ + #dryRun: boolean = false + constructor(manager: LimiterManager, options?: LimiterConsumptionOptions) { this.#manager = manager this.#options = options || {} @@ -126,6 +131,8 @@ export class HttpLimiter { + * 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. */ @@ -170,12 +212,14 @@ export class HttpLimiter 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) @@ -218,11 +277,20 @@ export class HttpLimiter { + async consume( + key: string | number, + amount?: number, + options?: { dryRun?: boolean } + ): Promise { 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 } @@ -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 } @@ -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] } diff --git a/src/limiter_manager.ts b/src/limiter_manager.ts index e029763..74a50ae 100644 --- a/src/limiter_manager.ts +++ b/src/limiter_manager.ts @@ -344,10 +344,13 @@ export class LimiterManager { * 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({ diff --git a/tests/throttle_middleware.spec.ts b/tests/throttle_middleware.spec.ts index 004a878..8859686 100644 --- a/tests/throttle_middleware.spec.ts +++ b/tests/throttle_middleware.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 LimiterRedisStore from '../src/stores/redis.ts' import { LimiterManager } from '../src/limiter_manager.ts' @@ -107,6 +107,66 @@ test.group('Throttle middleware', () => { } }) + test('set rate limit headers', async ({ assert }) => { + const redis = createRedis(['rlflx:api_1']).connection() + const limiterManager = new LimiterManager({ + default: 'redis', + stores: { + redis: (options) => new LimiterRedisStore(redis, options), + }, + }) + + const apiLimiter = limiterManager.define('api', () => { + return limiterManager.allowRequests(5).every('1 minute').usingKey(1) + }) + + const ctx = new HttpContextFactory().create() + await apiLimiter(ctx, () => {}) + + assert.equal(ctx.response.getHeader('X-RateLimit-Limit'), 5) + assert.equal(ctx.response.getHeader('X-RateLimit-Remaining'), 4) + }) + + test('call next without rate limit headers in dry run mode', async ({ assert }) => { + let nextCalls = 0 + + 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 apiLimiter = limiterManager.define('api', () => { + return limiterManager + .allowRequests(1) + .every('1 minute') + .usingKey(1) + .blockFor('30 mins') + .dryRun() + }) + + for (let i = 0; i < 3; i++) { + const ctx = new HttpContextFactory().create() + await apiLimiter(ctx, () => { + nextCalls++ + }) + + assert.isUndefined(ctx.response.getHeader('X-RateLimit-Limit')) + assert.isUndefined(ctx.response.getHeader('X-RateLimit-Remaining')) + assert.isUndefined(ctx.response.getHeader('Retry-After')) + } + + assert.equal(nextCalls, 3) + events.assertEmittedCount('limit:exceeded', 2) + events.assertNotEmitted('limit:exceeded', ({ data }) => !data.dryRun) + }) + test('do not throttle request when no limiter is used', async ({ assert }) => { let nextCalled: boolean = false