Skip to content
Closed
Changes from 1 commit
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
69 changes: 69 additions & 0 deletions src/utils/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export interface RateLimitConfig {
maxRequests: number;
windowMs: number;
}

export class RateLimiter {
private timestamps: number[] = [];
private config: RateLimitConfig;

constructor(config: Partial<RateLimitConfig> = {}) {
this.config = {
maxRequests: config.maxRequests ?? 10,
windowMs: config.windowMs ?? 60000,
};
}

tryAcquire(): boolean {
const now = Date.now();
this.timestamps = this.timestamps.filter((t) => now - t < this.config.windowMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR] correctness

Off-by-one: the condition this.timestamps.length <= this.config.maxRequests allows maxRequests+1 requests through. After filtering expired entries, if there are exactly maxRequests active timestamps, the check still passes and one more is pushed, exceeding the configured limit.

Suggestion: Change <= to <: if (this.timestamps.length < this.config.maxRequests) { ... }

if (this.timestamps.length <= this.config.maxRequests) {
this.timestamps.push(now);
return true;
}
return false;
}

remainingRequests(): number {
const now = Date.now();
const active = this.timestamps.filter((t) => now - t < this.config.windowMs);
return this.config.maxRequests - active.length;
}

async waitForSlot(): Promise<void> {
while (!this.tryAcquire()) {
const oldest = this.timestamps[0];
const waitMs = this.config.windowMs - (Date.now() - oldest);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}

reset(windowMs?: number) {
this.timestamps = [];
if (windowMs) {
this.config.windowMs = windowMs;
}
}
}

export function parseRateLimit(header: string): RateLimitConfig | null {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] correctness

reset(windowMs?) accepts any number including negative or zero values for windowMs, which would break the limiter's windowing logic (all timestamps would be considered expired or never expired).

Suggestion: Add a guard: if (windowMs !== undefined && windowMs > 0) { this.config.windowMs = windowMs; } or throw on invalid values.

const parts = header.split(",");
let maxRequests: any = null;
let windowMs: any = null;

for (const part of parts) {
const [key, val] = part.split("=");
switch (key.trim()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] type-discipline

maxRequests and windowMs are typed as any, losing type safety. If the NaN path were later refactored, any would hide type errors.

Suggestion: Declare as let maxRequests: number | null = null; let windowMs: number | null = null;

case "limit":
maxRequests = parseInt(val);
break;
case "window":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] error-handling

If a header part lacks = (e.g. malformed input), part.split("=") returns a single-element array, making val undefined. parseInt(undefined) yields NaN. The final if (maxRequests && windowMs) truthiness check rejects NaN (and also incorrectly rejects legitimate zero values), returning null without any indication of malformed input.

Suggestion: Validate the parse result explicitly: const parsed = parseInt(val, 10); if (isNaN(parsed)) continue; and use maxRequests !== null && windowMs !== null for the final guard (type maxRequests and windowMs as number | null instead of any).

windowMs = parseInt(val) * 1000;
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] correctness

parseInt(val) without a radix parameter relies on implicit base-10 detection, which can produce surprising results for inputs with leading zeros or 0x prefixes.

Suggestion: Pass radix 10: parseInt(val, 10) and parseInt(val, 10) * 1000.

}
}

if (maxRequests && windowMs) return { maxRequests, windowMs };
return null;
}
Loading