Swadhin Biswas Feature/ai code reviewer (#80) · 8/17/2026
9740d3d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
import Redis from "ioredis";
import { logger } from "@/lib/logger";
interface RateLimitEntry {
count: number;
resetTime: number;
}
export interface RateLimitDecision {
allowed: boolean;
remaining: number;
resetTime: number;
}
export interface RateLimiter {
check(identifier: string, limit: number, windowMs: number): Promise<RateLimitDecision>;
destroy(): void;
}
class InMemoryRateLimiter implements RateLimiter {
private store: Map<string, RateLimitEntry> = new Map();
private cleanupInterval: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
}
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (now > entry.resetTime) {
this.store.delete(key);
}
}
}
async check(
identifier: string,
limit: number,
windowMs: number,
): Promise<RateLimitDecision> {
const now = Date.now();
const entry = this.store.get(identifier);
if (!entry || now > entry.resetTime) {
const resetTime = now + windowMs;
this.store.set(identifier, { count: 1, resetTime });
return { allowed: true, remaining: limit - 1, resetTime };
}
if (entry.count >= limit) {
return { allowed: false, remaining: 0, resetTime: entry.resetTime };
}
entry.count++;
this.store.set(identifier, entry);
return {
allowed: true,
remaining: limit - entry.count,
resetTime: entry.resetTime,
};
}
destroy(): void {
clearInterval(this.cleanupInterval);
}
}
class RedisRateLimiter implements RateLimiter {
private readonly fallback = new InMemoryRateLimiter();
constructor(private readonly redis: Redis) {}
async check(
identifier: string,
limit: number,
windowMs: number,
): Promise<RateLimitDecision> {
const now = Date.now();
const windowSeconds = Math.ceil(windowMs / 1000);
const key = `ratelimit:${identifier}`;
try {
const results = await this.redis.multi().incr(key).pttl(key).exec();
if (!results) throw new Error("Redis transaction failed");
const countResult = results[0];
const ttlResult = results[1];
if (countResult[0]) throw countResult[0];
if (ttlResult[0]) throw ttlResult[0];
const count = countResult[1] as number;
let ttl = ttlResult[1] as number;
if (count === 1 || ttl === -1) {
await this.redis.pexpire(key, windowMs);
ttl = windowMs;
}
const resetTime = now + Math.max(ttl, 0);
const remaining = Math.max(0, limit - count);
return { allowed: count <= limit, remaining, resetTime };
} catch (error) {
logger.error({ error, identifier }, "Redis rate limit check failed, falling back to in-memory limiter");
return this.fallback.check(identifier, limit, windowMs);
}
}
destroy(): void {
// Redis handles connections automatically
}
}
let cached: RateLimiter | null = null;
export function getRateLimiter(): RateLimiter {
if (cached) return cached;
const skipRedis = process.env.SKIP_REDIS_CHECK === "1" || process.env.NODE_ENV === "test";
if (skipRedis) {
logger.warn("Using in-memory rate limiting (Redis skipped)");
cached = new InMemoryRateLimiter();
return cached;
}
try {
const url = process.env.REDIS_URL || "redis://localhost:6379";
const client: Redis = new Redis(url, {
enableReadyCheck: false,
maxRetriesPerRequest: 1,
lazyConnect: true,
});
cached = new RedisRateLimiter(client);
logger.info("Distributed rate limiting enabled (Redis)");
} catch (e) {
logger.warn({ error: e }, "Redis init failed — using in-memory rate limiting");
cached = new InMemoryRateLimiter();
}
return cached;
}
export const rateLimiter: RateLimiter = getRateLimiter();
export const isDistributed = !(rateLimiter instanceof InMemoryRateLimiter);