Skip to content
Closed
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
69 changes: 3 additions & 66 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 76 additions & 0 deletions src/utils/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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: number | null = null;
let windowMs: number | null = null;

for (const part of parts) {
const eqIdx = part.indexOf("=");
if (eqIdx === -1) continue;
const key = part.substring(0, eqIdx).trim();
const val = part.substring(eqIdx + 1);
switch (key) {
case "limit": {
const parsed = parseInt(val, 10);
if (!Number.isNaN(parsed)) maxRequests = parsed;
break;
}
case "window": {
const parsed = parseInt(val, 10);
if (!Number.isNaN(parsed)) windowMs = parsed * 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 !== null && windowMs !== null) return { maxRequests, windowMs };
return null;
}