Skip to content

feat: add dry run mode to HTTP limiters - #20

Merged
thetutlage merged 1 commit into
adonisjs:3.xfrom
MaximeMRF:feat/dry-run
Oct 19, 2026
Merged

thetutlage merged 1 commit into
adonisjs:3.xfrom
MaximeMRF:feat/dry-run

Conversation

@MaximeMRF

Copy link
Copy Markdown
Contributor

🔗 Linked issue

#19

❓ Type of change

  • ✨ New feature (a non-breaking change that adds functionality)

📚 Description

This PR adds a dry run mode to HttpLimiter. In dry run mode, requests are counted as usual, but never rejected: when the limit is exceeded, the limitExceeded callback is called with the exception that would have been thrown, and the request goes on.

export const throttle = limiter.define('login', () => {
  return limiter
    .allowRequests(10)
    .every('1 minute')
    .blockFor('30 mins')
    .dryRun()
    .limitExceeded((error) => {
      logger.warn(error.response, 'Request would have been throttled')
    })
})

It lets developers measure the impact of a limit on real traffic before enforcing it, like the "Count" action of a WAF.

Changes:

  • dryRun(enabled = true) enables the mode, so it can be driven by an env variable (.dryRun(env.get('LIMITER_DRY_RUN'))). isDryRun() returns it, and toJSON() includes dryRun: true only when enabled, so the existing output is unchanged.
  • throttle() returns the limiter response instead of throwing when the limit is exceeded in dry run mode, after calling the limitExceeded callback. The callback is called for every request over the limit, which makes it the place to log them.
  • Dry run keys use their own namespace (<name>_dry_<key>). Without it, with blockFor(), every key blocked during the dry run would be rejected as soon as .dryRun() is removed.
  • The X-RateLimit-Limit and X-RateLimit-Remaining headers are not set in dry run mode. The dry run should be invisible to clients: some API clients slow down on their own when X-RateLimit-Remaining reaches 0, which would skew the observation, and bots would learn the limit before it is enforced.

Tests cover the config, the requests going through with the callback being called, the separate keys with blockFor(), and the middleware calling next() without headers. I also added a test for the headers in the normal mode, which had none.

Two questions:

  • Is dryRun() the right name? reportOnly() (like the CSP report only mode) would work too.
  • Should dry run keys stay separated, or share the keys of the enforced mode? Separated keys look safer to me for the blockFor() reason above.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

I will send the docs PR to v7-docs once this one is accepted.

@thetutlage

Copy link
Copy Markdown
Member

Hello @MaximeMRF

The proposal looks great, but I think we should also think through how the observability can be done when the main goal is to observe the limits and tune the numbers.

Right now we are leaning into the limitExceeded callback, which is not the right place and we are making one method do two different things. I would like us explore either per limiter observer or a global observer that can be defined once and listens for all dry-run limits exceeding events.

@thetutlage

Copy link
Copy Markdown
Member

I just added support for emitting and event when the limit is exceeded and the same event can now be extended with a dryRun flag to globally listen for the events

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.
@MaximeMRF

Copy link
Copy Markdown
Contributor Author

Rebased on top of #21 as suggested. Dry run no longer touches limitExceeded: when a limit would be exceeded, the limit:exceeded event is emitted with dryRun: true and the request goes through. Dry run counters use separate keys (<prefix>_dry_<key>), so keys blocked during observation aren't rejected once the limit is enforced, and no X-RateLimit-* headers are set.

To avoid a duplicate event when the store rejects, Limiter.consume() takes an optional { dryRun } that only flags the event. I also made dryRun a required boolean on LimiterExceededEvent (always false outside dry run) so listeners don't have to handle undefined. Happy to make it optional instead if you prefer.

@thetutlage
thetutlage merged commit c9abb76 into adonisjs:3.x Oct 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants