Skip to content

feat(rate-limit): rate-limiter module#126

Merged
Sorikairox merged 3 commits into
mainfrom
feat/rate-limit
Jun 7, 2026
Merged

feat(rate-limit): rate-limiter module#126
Sorikairox merged 3 commits into
mainfrom
feat/rate-limit

Conversation

@Sorikairox

@Sorikairox Sorikairox commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Description


Issue Ticket Number

Fixes #123


Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to
    not work as expected)
  • This change requires a documentation update

Checklist:

  • I have run deno lint AND deno fmt AND deno task test and got no
    errors.
  • I have followed the contributing guidelines of this project as mentioned
    in CONTRIBUTING.md
  • I have checked to ensure there aren't other open
    Pull Requests for the same
    update/change?
  • I have performed a self-review of my own code
  • I have made corresponding changes needed to the documentation

Summary by CodeRabbit

  • New Features

    • Added configurable rate-limiting with named throttlers, per-route overrides, a skip option, and library-level export.
  • Tests

    • Added comprehensive tests for throttling behavior, headers, TTL reset, decorator overrides, storage eviction, and multi-throttler scenarios.
  • Documentation

    • Added contributor guidance covering architecture, commands, testing conventions, and contribution pointers.
  • Bug Fixes

    • Improved in-memory storage eviction and shutdown cleanup to avoid lingering timers.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1311e89-7adc-42f3-9c26-d61eff6e5e8d

📥 Commits

Reviewing files that changed from the base of the PR and between 267bd5a and 98f37ee.

📒 Files selected for processing (1)
  • .github/workflows/run-tests.yml

📝 Walkthrough

Walkthrough

Adds a complete throttling subsystem: types and DI/metadata constants, decorators (@Throttle/@SkipThrottle), ThrottlerGuard + ThrottlerException, InMemoryThrottlerStorage with eviction and lifecycle cleanup, ThrottlerModule wiring and re-exports, comprehensive tests, documentation, a small schedule typing fix, and a CI Deno matrix update.

Changes

Throttler/Rate-Limiter Feature

Layer / File(s) Summary
Type Contracts & Constants
src/throttler/interface.ts, src/throttler/constants.ts
Defines ThrottlerOptions (ttl, limit, optional name), ThrottlerStorageRecord, ThrottlerStorage interface, ThrottleOptions override map, and DI/metadata constant tokens.
Rate-Limiting Guard & Exception
src/throttler/guard.ts
Implements ThrottlerGuard and ThrottlerException (HTTP 429). Guard checks skip/override metadata, computes proxy-aware request keys, increments storage counters, sets rate-limit headers, and throws when limit is exceeded.
Throttling Decorators
src/throttler/decorator.ts
Provides @Throttle(options) and @SkipThrottle(skip) decorators for per-route limit override and bypass behavior.
In-Memory Storage Implementation
src/throttler/storage.ts
Implements InMemoryThrottlerStorage with per-window hit tracking, absolute expiry, automatic Deno timer-based eviction, size getter, and shutdown cleanup.
Module Configuration & Public API
src/throttler/module.ts, src/throttler/mod.ts, src/mod.ts
Introduces ThrottlerModule.forRoot() for dynamic configuration with DI wiring, creates module entrypoint and re-exports, and integrates throttler into root framework barrel.
Comprehensive Test Suite
spec/rate-limiter.test.ts
Validates rate limiting (blocking, headers, TTL reset), decorator behavior (@SkipThrottle, @Throttle overrides), storage eviction, and multiple independent named throttlers.
Contributor Documentation
CLAUDE.md
Adds framework overview, Deno tooling commands, architecture/request flow walkthrough, testing conventions, formatting rules, and contribution guidance.
Schedule typing change
src/schedule/module.ts
Updates interval/timeout handle types to use ReturnType<typeof setInterval/setTimeout>.
CI Deno matrix update
.github/workflows/run-tests.yml
Replaces canary with v2.x in the Deno matrix.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ThrottlerGuard
  participant InMemoryThrottlerStorage
  participant Response
  Client->>ThrottlerGuard: HTTP request (context)
  ThrottlerGuard->>ThrottlerGuard: Read `@SkipThrottle` / `@Throttle` metadata
  ThrottlerGuard->>ThrottlerGuard: Resolve client key (x-forwarded-for / x-real-ip / remoteAddr)
  ThrottlerGuard->>InMemoryThrottlerStorage: increment(key, ttl)
  InMemoryThrottlerStorage->>ThrottlerGuard: { totalHits, timeToExpire }
  alt totalHits > limit
    ThrottlerGuard->>Response: throw ThrottlerException (429) + set Retry-After
  else within limit
    ThrottlerGuard->>Response: set X-RateLimit-Limit/Remaining/Reset headers
    Response->>Client: proceed
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

A rabbit counts each hop and chime,
Windows tick down, then start in time.
Guards stand ready, decorators play,
Storage clears the past away.
Danet breathes steady — hop, not fray. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(rate-limit): rate-limiter module' accurately summarizes the main feature added—a new rate-limiter module for the framework.
Description check ✅ Passed The PR description follows the repository template with all required sections completed: clear marking as a new feature, issue reference (#123), checklist items marked, and self-review confirmation.
Linked Issues check ✅ Passed The code changes implement all primary objectives from issue #123: a built-in rate-limiting module with decorators, guards, storage interfaces, and an in-memory implementation following OOP patterns similar to NestJS throttler.
Out of Scope Changes check ✅ Passed Minor fix to schedule/module.ts for timer handle types is a supporting technical improvement; all other changes directly implement the rate-limiter feature requested in issue #123.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rate-limit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Sorikairox Sorikairox self-assigned this Jun 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
CLAUDE.md (1)

115-124: ⚡ Quick win

Make the test example cleanup exception-safe with try/finally.

The sample calls await app.close(), but if fetch() or an assertion throws first, cleanup is skipped. Wrapping the test body in try/finally prevents leaked listeners in copied tests.

Suggested doc patch
 Deno.test('GET', async () => {
   const app = new DanetApplication();
-  await app.init(MyModule);
-  const { port } = await app.listen(0); // 0 = random free port
-
-  const res = await fetch(`http://localhost:${port}/nice-controller`);
-  assertEquals(await res.text(), 'OK GET');
-
-  await app.close(); // always clean up
+  try {
+    await app.init(MyModule);
+    const { port } = await app.listen(0); // 0 = random free port
+
+    const res = await fetch(`http://localhost:${port}/nice-controller`);
+    assertEquals(await res.text(), 'OK GET');
+  } finally {
+    await app.close(); // always clean up
+  }
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 115 - 124, The test snippet using DanetApplication
needs exception-safe cleanup: wrap the test body that calls app.init(MyModule)
and app.listen(0) and performs fetch/assertions in a try/finally so that
app.close() is always called; specifically, after creating const app = new
DanetApplication() and awaiting app.init(...) and app.listen(...), move the
fetch/assert logic into a try block and call await app.close() in the finally
block to guarantee listener cleanup even if fetch() or assertions throw.
src/throttler/guard.ts (1)

119-131: ⚡ Quick win

Be aware of proxy header spoofing risk.

The getTracker method trusts x-forwarded-for and x-real-ip headers to identify clients behind proxies. These headers can be spoofed by malicious clients if the application is directly exposed to the internet without a trusted reverse proxy. This could allow attackers to:

  • Bypass rate limits by rotating the header value
  • Frame other IP addresses to exhaust their quotas

Mitigation:

  • Deploy behind a trusted reverse proxy that strips/rewrites these headers
  • Override getTracker to validate headers or use authenticated identity (API key, user ID) for critical routes
  • Consider using remoteAddr only if not behind a proxy

The method is correctly marked protected to enable customization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/throttler/guard.ts` around lines 119 - 131, The getTracker method
currently trusts request headers ('x-forwarded-for', 'x-real-ip') directly;
update it to mitigate header spoofing by: 1) checking a configured
"trustedProxy" flag or list before using 'x-forwarded-for'/'x-real-ip' inside
getTracker(ExecutionContext) and falling back to remoteAddr.hostname if not
trusted; 2) optionally prefer authenticated identity (API key, user ID) when
available (e.g., accept a token or context.user id) for rate-limiting; and 3)
document/throw if headers are present but not from a trusted proxy so callers
can override getTracker safely. Ensure changes reference getTracker,
ExecutionContext, and the header names so reviewers can locate and customize
behavior.
spec/rate-limiter.test.ts (1)

212-224: 💤 Low value

Consider increasing the eviction wait time for test stability.

Line 222 waits 100ms for two 50ms timers to fire and evict their keys. While a 2× buffer is often sufficient, timer precision can vary on slower systems or under CI load. If this test becomes flaky, consider increasing the wait to 150ms or 200ms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/rate-limiter.test.ts` around lines 212 - 224, The test that asserts
eviction in InMemoryThrottlerStorage is waiting only 100ms for two 50ms timers
to run and evict keys; increase the eviction wait in that test (the
Promise/setTimeout after the storage.increment calls) to a larger value (e.g.,
150ms or 200ms) so timer jitter under CI/slower machines won't cause flakes
while keeping the same assertions against storage.size and the same use of
InMemoryThrottlerStorage and storage.increment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/throttler/guard.ts`:
- Around line 49-92: The header suffix logic in canActivate incorrectly checks
throttler.name (which can be undefined) instead of the resolved name variable
(which uses DEFAULT_THROTTLER_NAME), causing unnamed throttlers to produce
identical headers and collide; fix by using the resolved name when building the
suffix (i.e., use name when computing the suffix/suffix condition for
X-RateLimit-* and Retry-After headers) so unnamed throttlers receive the
default-based suffix and headers remain unique in multi-throttler setups.

In `@src/throttler/storage.ts`:
- Around line 13-17: The records map in InMemoryThrottlerStorage declares
timeoutId as number but Deno's setTimeout returns a Timeout object, causing type
errors; update the timeoutId type to a portable type such as ReturnType<typeof
setTimeout> (or the union needed in your runtime) on the records entry so
assignments from setTimeout match the declared type, and adjust any
clearTimeout/uses accordingly to accept that type; target the records
declaration and any direct uses of timeoutId in InMemoryThrottlerStorage.

---

Nitpick comments:
In `@CLAUDE.md`:
- Around line 115-124: The test snippet using DanetApplication needs
exception-safe cleanup: wrap the test body that calls app.init(MyModule) and
app.listen(0) and performs fetch/assertions in a try/finally so that app.close()
is always called; specifically, after creating const app = new
DanetApplication() and awaiting app.init(...) and app.listen(...), move the
fetch/assert logic into a try block and call await app.close() in the finally
block to guarantee listener cleanup even if fetch() or assertions throw.

In `@spec/rate-limiter.test.ts`:
- Around line 212-224: The test that asserts eviction in
InMemoryThrottlerStorage is waiting only 100ms for two 50ms timers to run and
evict keys; increase the eviction wait in that test (the Promise/setTimeout
after the storage.increment calls) to a larger value (e.g., 150ms or 200ms) so
timer jitter under CI/slower machines won't cause flakes while keeping the same
assertions against storage.size and the same use of InMemoryThrottlerStorage and
storage.increment.

In `@src/throttler/guard.ts`:
- Around line 119-131: The getTracker method currently trusts request headers
('x-forwarded-for', 'x-real-ip') directly; update it to mitigate header spoofing
by: 1) checking a configured "trustedProxy" flag or list before using
'x-forwarded-for'/'x-real-ip' inside getTracker(ExecutionContext) and falling
back to remoteAddr.hostname if not trusted; 2) optionally prefer authenticated
identity (API key, user ID) when available (e.g., accept a token or context.user
id) for rate-limiting; and 3) document/throw if headers are present but not from
a trusted proxy so callers can override getTracker safely. Ensure changes
reference getTracker, ExecutionContext, and the header names so reviewers can
locate and customize behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 793b2346-1947-43eb-ad60-27bbcd59463c

📥 Commits

Reviewing files that changed from the base of the PR and between 9099a39 and aef51fa.

📒 Files selected for processing (10)
  • CLAUDE.md
  • spec/rate-limiter.test.ts
  • src/mod.ts
  • src/throttler/constants.ts
  • src/throttler/decorator.ts
  • src/throttler/guard.ts
  • src/throttler/interface.ts
  • src/throttler/mod.ts
  • src/throttler/module.ts
  • src/throttler/storage.ts

Comment thread src/throttler/guard.ts
Comment on lines +49 to +92
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const classRef = context.getClass();

if (this.shouldSkip(handler) || this.shouldSkip(classRef)) {
return true;
}

const overrides = this.getOverrides(handler, classRef);
// When several throttlers are configured (or a single named one), the
// rate-limit headers are suffixed with the throttler name, matching
// the behavior of `@nestjs/throttler`.
const useSuffix = this.options.length > 1 ||
Boolean(this.options[0]?.name);

for (const throttler of this.options) {
const name = throttler.name ?? DEFAULT_THROTTLER_NAME;
const override = overrides?.[name];
const limit = override?.limit ?? throttler.limit;
const ttl = override?.ttl ?? throttler.ttl;

const key = this.generateKey(context, name);
const { totalHits, timeToExpire } = await this.storage.increment(
key,
ttl,
);

const suffix = useSuffix && throttler.name ? `-${throttler.name}` : '';
const resetSeconds = Math.ceil(timeToExpire / 1000);
context.header(`X-RateLimit-Limit${suffix}`, String(limit));
context.header(
`X-RateLimit-Remaining${suffix}`,
String(Math.max(0, limit - totalHits)),
);
context.header(`X-RateLimit-Reset${suffix}`, String(resetSeconds));

if (totalHits > limit) {
context.header(`Retry-After${suffix}`, String(resetSeconds));
throw new ThrottlerException();
}
}

return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix header suffix logic to prevent collisions in multi-throttler setups.

When multiple throttlers are configured and some lack explicit names, the current suffix logic on line 76 evaluates throttler.name (which is undefined for unnamed throttlers) instead of the name variable (which includes the DEFAULT_THROTTLER_NAME fallback from line 65). This causes multiple unnamed throttlers to set identical header names (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) without differentiation, leading to header overwrites and breaking multi-throttler observability.

The same issue affects the Retry-After header on line 86.

🔧 Proposed fix
-		const suffix = useSuffix && throttler.name ? `-${throttler.name}` : '';
+		const suffix = useSuffix ? `-${name}` : '';

This ensures that unnamed throttlers receive the -default suffix when useSuffix is true, preventing header collisions.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const classRef = context.getClass();
if (this.shouldSkip(handler) || this.shouldSkip(classRef)) {
return true;
}
const overrides = this.getOverrides(handler, classRef);
// When several throttlers are configured (or a single named one), the
// rate-limit headers are suffixed with the throttler name, matching
// the behavior of `@nestjs/throttler`.
const useSuffix = this.options.length > 1 ||
Boolean(this.options[0]?.name);
for (const throttler of this.options) {
const name = throttler.name ?? DEFAULT_THROTTLER_NAME;
const override = overrides?.[name];
const limit = override?.limit ?? throttler.limit;
const ttl = override?.ttl ?? throttler.ttl;
const key = this.generateKey(context, name);
const { totalHits, timeToExpire } = await this.storage.increment(
key,
ttl,
);
const suffix = useSuffix && throttler.name ? `-${throttler.name}` : '';
const resetSeconds = Math.ceil(timeToExpire / 1000);
context.header(`X-RateLimit-Limit${suffix}`, String(limit));
context.header(
`X-RateLimit-Remaining${suffix}`,
String(Math.max(0, limit - totalHits)),
);
context.header(`X-RateLimit-Reset${suffix}`, String(resetSeconds));
if (totalHits > limit) {
context.header(`Retry-After${suffix}`, String(resetSeconds));
throw new ThrottlerException();
}
}
return true;
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const classRef = context.getClass();
if (this.shouldSkip(handler) || this.shouldSkip(classRef)) {
return true;
}
const overrides = this.getOverrides(handler, classRef);
// When several throttlers are configured (or a single named one), the
// rate-limit headers are suffixed with the throttler name, matching
// the behavior of `@nestjs/throttler`.
const useSuffix = this.options.length > 1 ||
Boolean(this.options[0]?.name);
for (const throttler of this.options) {
const name = throttler.name ?? DEFAULT_THROTTLER_NAME;
const override = overrides?.[name];
const limit = override?.limit ?? throttler.limit;
const ttl = override?.ttl ?? throttler.ttl;
const key = this.generateKey(context, name);
const { totalHits, timeToExpire } = await this.storage.increment(
key,
ttl,
);
const suffix = useSuffix ? `-${name}` : '';
const resetSeconds = Math.ceil(timeToExpire / 1000);
context.header(`X-RateLimit-Limit${suffix}`, String(limit));
context.header(
`X-RateLimit-Remaining${suffix}`,
String(Math.max(0, limit - totalHits)),
);
context.header(`X-RateLimit-Reset${suffix}`, String(resetSeconds));
if (totalHits > limit) {
context.header(`Retry-After${suffix}`, String(resetSeconds));
throw new ThrottlerException();
}
}
return true;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/throttler/guard.ts` around lines 49 - 92, The header suffix logic in
canActivate incorrectly checks throttler.name (which can be undefined) instead
of the resolved name variable (which uses DEFAULT_THROTTLER_NAME), causing
unnamed throttlers to produce identical headers and collide; fix by using the
resolved name when building the suffix (i.e., use name when computing the
suffix/suffix condition for X-RateLimit-* and Retry-After headers) so unnamed
throttlers receive the default-based suffix and headers remain unique in
multi-throttler setups.

Comment thread src/throttler/storage.ts
@Sorikairox
Sorikairox merged commit a6c2e7a into main Jun 7, 2026
2 of 3 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.

Rate Limiting Facilities Are Missing from The Framework Point of View

1 participant