Skip to content

Commit 893e074

Browse files
fix: atomic increment and other minor issues suggested by coderabbit
1 parent 6e1b3f6 commit 893e074

File tree

5 files changed

+144
-18
lines changed

5 files changed

+144
-18
lines changed

src/core/cache.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,50 @@ describe("MemoryCache", () => {
5858
expect(cache.get("key1")).toBeNull();
5959
expect(cache.get("key2")).toBe("value2");
6060
});
61+
62+
it("should increment a non-existent key to 1", () => {
63+
const result = cache.incr("counter", 60);
64+
expect(result).toBe(1);
65+
expect(cache.get("counter")).toBe("1");
66+
});
67+
68+
it("should increment an existing key", () => {
69+
cache.set("counter", "5", 60);
70+
const result = cache.incr("counter", 60);
71+
// biome-ignore lint/style/noMagicNumbers: 6 is expected incremented value
72+
expect(result).toBe(6);
73+
expect(cache.get("counter")).toBe("6");
74+
});
75+
76+
it("should increment multiple times", () => {
77+
expect(cache.incr("counter", 60)).toBe(1);
78+
expect(cache.incr("counter", 60)).toBe(2);
79+
// biome-ignore lint/style/noMagicNumbers: 3 is expected incremented value
80+
expect(cache.incr("counter", 60)).toBe(3);
81+
expect(cache.get("counter")).toBe("3");
82+
});
83+
84+
it("should handle increment with TTL", async () => {
85+
// biome-ignore lint/style/noMagicNumbers: 100ms
86+
cache.incr("counter", 0.1);
87+
expect(cache.get("counter")).toBe("1");
88+
89+
// biome-ignore lint/style/noMagicNumbers: 150ms
90+
await new Promise((resolve) => setTimeout(resolve, 150));
91+
expect(cache.get("counter")).toBeNull();
92+
});
93+
94+
it("should not increment expired keys", async () => {
95+
// biome-ignore lint/style/noMagicNumbers: 100ms
96+
cache.set("counter", "5", 0.1);
97+
98+
// biome-ignore lint/style/noMagicNumbers: 150ms
99+
await new Promise((resolve) => setTimeout(resolve, 150));
100+
101+
const result = cache.incr("counter", 60);
102+
expect(result).toBe(1);
103+
expect(cache.get("counter")).toBe("1");
104+
});
61105
});
62106

63107
describe("RedisCache", () => {
@@ -69,6 +113,7 @@ describe("RedisCache", () => {
69113
get: vi.fn(),
70114
setex: vi.fn(),
71115
del: vi.fn(),
116+
eval: vi.fn(),
72117
};
73118
cache = new RedisCache(mockRedisClient);
74119
});
@@ -101,4 +146,30 @@ describe("RedisCache", () => {
101146
const result = await cache.get("non-existent");
102147
expect(result).toBeNull();
103148
});
149+
150+
it("should call redis eval with Lua script for incr", async () => {
151+
// biome-ignore lint/style/noMagicNumbers: 5 as the incremented value
152+
mockRedisClient.eval.mockResolvedValue(5);
153+
154+
// biome-ignore lint/style/noMagicNumbers: 120 seconds
155+
const result = await cache.incr("counter", 120);
156+
157+
// biome-ignore lint/style/noMagicNumbers: 5 as the incremented value
158+
expect(result).toBe(5);
159+
expect(mockRedisClient.eval).toHaveBeenCalledWith(
160+
expect.stringContaining("INCR"),
161+
1,
162+
"counter",
163+
// biome-ignore lint/style/noMagicNumbers: 120 seconds
164+
120
165+
);
166+
});
167+
168+
it("should handle incr returning number", async () => {
169+
mockRedisClient.eval.mockResolvedValue(1);
170+
171+
const result = await cache.incr("new-counter", 60);
172+
expect(result).toBe(1);
173+
expect(typeof result).toBe("number");
174+
});
104175
});

src/core/cache.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ export type Cache = {
44
get(key: string): Promise<string | null> | string | null;
55
set(key: string, value: string, ttl?: number): Promise<void> | void;
66
del(key: string): Promise<void> | void;
7+
/**
8+
* Atomically increments the value at key by 1 and returns the new value.
9+
* If the key doesn't exist, it's set to 1.
10+
* @param key - The cache key
11+
* @param ttl - Time to live in seconds
12+
* @returns The new value after incrementing
13+
*/
14+
incr(key: string, ttl?: number): Promise<number> | number;
715
};
816

917
export class MemoryCache implements Cache {
@@ -38,6 +46,21 @@ export class MemoryCache implements Cache {
3846
this.cache.delete(key);
3947
}
4048

49+
incr(key: string, ttl = 60): number {
50+
const item = this.cache.get(key);
51+
const currentValue =
52+
item && item.expires >= Date.now() ? Number.parseInt(item.value, 10) : 0;
53+
const newValue = currentValue + 1;
54+
55+
this.cache.set(key, {
56+
value: String(newValue),
57+
// biome-ignore lint/style/noMagicNumbers: 1000ms to seconds
58+
expires: Date.now() + ttl * 1000,
59+
});
60+
61+
return newValue;
62+
}
63+
4164
clear(): void {
4265
this.cache.clear();
4366
}
@@ -61,4 +84,17 @@ export class RedisCache implements Cache {
6184
async del(key: string): Promise<void> {
6285
await this.client.del(key);
6386
}
87+
88+
async incr(key: string, ttl = 60): Promise<number> {
89+
// Use Redis INCR which is atomic, then set TTL
90+
// Using a Lua script ensures both operations are atomic
91+
const script = `
92+
local count = redis.call('INCR', KEYS[1])
93+
redis.call('EXPIRE', KEYS[1], ARGV[1])
94+
return count
95+
`;
96+
97+
const result = await this.client.eval(script, 1, key, ttl);
98+
return Number(result);
99+
}
64100
}

src/core/rate-limiter.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ describe("RateLimiter", () => {
5252
const result = await rateLimiter.check(apiKeyRecord);
5353

5454
expect(result.allowed).toBe(false);
55-
expect(result.current).toBe(REQUEST_COUNT);
55+
// With atomic increment, the counter increments before checking,
56+
// so the 6th request will show current: 6
57+
expect(result.current).toBe(REQUEST_COUNT + 1);
5658
expect(result.remaining).toBe(0);
5759
});
5860

@@ -176,11 +178,11 @@ describe("RateLimiter", () => {
176178
try {
177179
await redis.connect();
178180
await redis.ping();
179-
} catch (error) {
180-
console.warn(
181+
} catch {
182+
it.skip(
181183
"Redis not available. Skipping Redis tests. Start with: bun run redis:up"
182184
);
183-
throw error;
185+
return;
184186
}
185187

186188
const keyManager = createKeys({

src/core/rate-limiter.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export class RateLimiter {
1313
private readonly keyPrefix: string;
1414

1515
constructor(cache: Cache, config: RateLimitConfig) {
16+
if (config.windowMs <= 0) {
17+
throw new Error("windowMs must be a positive number");
18+
}
19+
if (config.maxRequests <= 0) {
20+
throw new Error("maxRequests must be a positive number");
21+
}
22+
1623
this.cache = cache;
1724
this.config = config;
1825
this.keyPrefix = config.keyPrefix ?? "ratelimit";
@@ -46,32 +53,42 @@ export class RateLimiter {
4653
this.config.windowMs / MILLISECONDS_PER_SECOND
4754
);
4855

49-
const currentValue = await this.cache.get(key);
50-
const current = currentValue ? Number.parseInt(currentValue, 10) : 0;
56+
if (increment) {
57+
// Use atomic increment to prevent race conditions
58+
const newCount = await this.cache.incr(key, ttlSeconds);
59+
60+
if (newCount > this.config.maxRequests) {
61+
return {
62+
allowed: false,
63+
current: newCount,
64+
limit: this.config.maxRequests,
65+
resetMs,
66+
resetAt: new Date(resetAt).toISOString(),
67+
remaining: 0,
68+
};
69+
}
5170

52-
if (current >= this.config.maxRequests) {
5371
return {
54-
allowed: false,
55-
current,
72+
allowed: true,
73+
current: newCount,
5674
limit: this.config.maxRequests,
5775
resetMs,
5876
resetAt: new Date(resetAt).toISOString(),
59-
remaining: 0,
77+
remaining: this.config.maxRequests - newCount,
6078
};
6179
}
6280

63-
const newCount = increment ? current + 1 : current;
64-
if (increment) {
65-
await this.cache.set(key, String(newCount), ttlSeconds);
66-
}
81+
// When not incrementing, just check the current value
82+
const currentValue = await this.cache.get(key);
83+
const current = currentValue ? Number.parseInt(currentValue, 10) : 0;
6784

6885
return {
69-
allowed: true,
70-
current: newCount,
86+
allowed: current < this.config.maxRequests,
87+
current,
7188
limit: this.config.maxRequests,
7289
resetMs,
7390
resetAt: new Date(resetAt).toISOString(),
74-
remaining: this.config.maxRequests - newCount,
91+
remaining: Math.max(0, this.config.maxRequests - current),
7592
};
7693
}
7794

src/types/rate-limit-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type RateLimitResult = {
2222
limit: number;
2323
/** Time in milliseconds until the window resets */
2424
resetMs: number;
25-
/** ISO timestampt when the window resets */
25+
/** ISO timestamp when the window resets */
2626
resetAt: string;
2727
/** Number of requests remaining within the window */
2828
remaining: number;

0 commit comments

Comments
 (0)