Skip to content

Commit 6d1aebd

Browse files
committed
tests
1 parent ad4ec1a commit 6d1aebd

File tree

3 files changed

+67
-220
lines changed

3 files changed

+67
-220
lines changed

.changeset/open-ends-melt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@thirdweb-dev/service-utils": patch
3+
---
4+
5+
Update rate limit to sliding window

packages/service-utils/src/core/rateLimit/index.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export async function rateLimit(args: {
4545
getRequestCountAtSecondCacheKey(serviceScope, team.id, currentSecond - i),
4646
);
4747
const counts = await redis.mget(keys);
48-
const countCurrentSecond = counts[0] ? Number.parseInt(counts[0]) : 0;
4948
const totalCount = counts.reduce(
5049
(sum, count) => sum + (count ? Number.parseInt(count) : 0),
5150
0,
@@ -65,26 +64,26 @@ export async function rateLimit(args: {
6564
}
6665

6766
// Non-blocking: increment the request count for the current second.
68-
async () => {
67+
(async () => {
6968
try {
7069
const key = getRequestCountAtSecondCacheKey(
7170
serviceScope,
7271
team.id,
7372
currentSecond,
7473
);
7574
await redis.incrby(key, increment);
76-
// If the initial request count was 0, set the key to expire in the future.
77-
if (countCurrentSecond === 0) {
75+
// If this is the first time setting this key, expire it after the sliding window is past.
76+
if (counts[0] === null) {
7877
await redis.expire(key, SLIDING_WINDOW_SECONDS + 1);
7978
}
8079
} catch (error) {
8180
console.error("Error updating rate limit key:", error);
8281
}
83-
};
82+
})();
8483

8584
return {
8685
rateLimited: false,
87-
requestCount: countCurrentSecond + increment,
86+
requestCount: totalCount + increment,
8887
rateLimit: limitPerWindow,
8988
};
9089
}

packages/service-utils/src/core/rateLimit/rateLimit.test.ts

Lines changed: 57 additions & 214 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import { validServiceConfig, validTeamResponse } from "../../mocks.js";
33
import { rateLimit } from "./index.js";
44

5+
const SLIDING_WINDOW_SECONDS = 10;
6+
57
const mockRedis = {
6-
get: vi.fn(),
8+
mget: vi.fn(),
79
expire: vi.fn(),
810
incrby: vi.fn(),
911
};
1012

1113
describe("rateLimit", () => {
1214
beforeEach(() => {
13-
// Clear mock function calls and reset any necessary state.
1415
vi.clearAllMocks();
15-
mockRedis.get.mockReset();
16-
mockRedis.expire.mockReset();
17-
mockRedis.incrby.mockReset();
16+
// Mock current time to a fixed value
17+
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
1818
});
1919

2020
afterEach(() => {
21-
vi.spyOn(global.Math, "random").mockRestore();
21+
vi.useRealTimers();
2222
});
2323

24-
it("should not rate limit if service scope is not in rate limits", async () => {
24+
it("should not rate limit if limitPerSecond is 0", async () => {
2525
const result = await rateLimit({
2626
team: validTeamResponse,
2727
limitPerSecond: 0,
@@ -34,254 +34,97 @@ describe("rateLimit", () => {
3434
requestCount: 0,
3535
rateLimit: 0,
3636
});
37+
expect(mockRedis.mget).not.toHaveBeenCalled();
3738
});
3839

39-
it("should not rate limit if within limit", async () => {
40-
mockRedis.get.mockResolvedValue("50"); // Current count is 50 requests in 10 seconds.
41-
42-
const result = await rateLimit({
43-
team: validTeamResponse,
44-
limitPerSecond: 5,
45-
serviceConfig: validServiceConfig,
46-
redis: mockRedis,
47-
});
48-
49-
expect(result).toEqual({
50-
rateLimited: false,
51-
requestCount: 51,
52-
rateLimit: 50,
53-
});
54-
55-
expect(mockRedis.incrby).toHaveBeenCalledTimes(1);
56-
});
57-
58-
it("should rate limit if exceeded hard limit", async () => {
59-
mockRedis.get.mockResolvedValue(51);
40+
it("should check last 10 seconds of requests", async () => {
41+
const currentSecond = Math.floor(Date.now() / 1000);
42+
mockRedis.mget.mockResolvedValue([
43+
null, // current second
44+
"5",
45+
null,
46+
"3",
47+
"1",
48+
null,
49+
"17",
50+
null,
51+
"5",
52+
null,
53+
]);
6054

6155
const result = await rateLimit({
6256
team: validTeamResponse,
63-
limitPerSecond: 5,
57+
limitPerSecond: 10,
6458
serviceConfig: validServiceConfig,
6559
redis: mockRedis,
6660
});
6761

68-
expect(result).toEqual({
69-
rateLimited: true,
70-
requestCount: 51,
71-
rateLimit: 50,
72-
status: 429,
73-
errorMessage: `You've exceeded your storage rate limit at 5 reqs/sec. To get higher rate limits, contact us at https://thirdweb.com/contact-us.`,
74-
errorCode: "RATE_LIMIT_EXCEEDED",
75-
});
76-
77-
expect(mockRedis.incrby).not.toHaveBeenCalled();
78-
});
79-
80-
it("expires on the first incr request only", async () => {
81-
mockRedis.get.mockResolvedValue("1");
82-
83-
const result = await rateLimit({
84-
team: validTeamResponse,
85-
limitPerSecond: 5,
86-
serviceConfig: validServiceConfig,
87-
redis: mockRedis,
88-
});
62+
// Verify correct keys are checked
63+
const expectedKeys = Array.from(
64+
{ length: SLIDING_WINDOW_SECONDS },
65+
(_, i) => `rate-limit:storage:1:${currentSecond - i}`,
66+
);
67+
expect(mockRedis.mget).toHaveBeenCalledWith(expectedKeys);
8968

90-
expect(result).toEqual({
91-
rateLimited: false,
92-
requestCount: 2,
93-
rateLimit: 50,
94-
});
95-
expect(mockRedis.incrby).toHaveBeenCalled();
69+
expect(result.requestCount).toBe(32);
70+
expect(result.rateLimit).toBe(100);
71+
expect(result.rateLimited).toBe(false);
9672
});
9773

98-
it("enforces rate limit if sampled (hit)", async () => {
99-
mockRedis.get.mockResolvedValue("10");
100-
vi.spyOn(global.Math, "random").mockReturnValue(0.08);
74+
it("should rate limit when total count exceeds limit", async () => {
75+
// 101 total requests
76+
mockRedis.mget.mockResolvedValue(["50", "51"]);
10177

10278
const result = await rateLimit({
10379
team: validTeamResponse,
104-
limitPerSecond: 5,
80+
limitPerSecond: 10,
10581
serviceConfig: validServiceConfig,
10682
redis: mockRedis,
107-
sampleRate: 0.1,
10883
});
10984

110-
expect(result).toEqual({
85+
expect(result).toMatchObject({
11186
rateLimited: true,
112-
requestCount: 10,
113-
rateLimit: 5,
87+
requestCount: 101,
88+
rateLimit: 100,
11489
status: 429,
115-
errorMessage:
116-
"You've exceeded your storage rate limit at 5 reqs/sec. To get higher rate limits, contact us at https://thirdweb.com/contact-us.",
11790
errorCode: "RATE_LIMIT_EXCEEDED",
11891
});
11992
});
12093

121-
it("does not enforce rate limit if sampled (miss)", async () => {
122-
mockRedis.get.mockResolvedValue(10);
123-
vi.spyOn(global.Math, "random").mockReturnValue(0.15);
124-
125-
const result = await rateLimit({
94+
it("should set expiry only when current second count is 0", async () => {
95+
// First case: current second has no requests
96+
mockRedis.mget.mockResolvedValueOnce([null, ...Array(9).fill("5")]);
97+
await rateLimit({
12698
team: validTeamResponse,
127-
limitPerSecond: 5,
99+
limitPerSecond: 10,
128100
serviceConfig: validServiceConfig,
129101
redis: mockRedis,
130-
sampleRate: 0.1,
131102
});
103+
expect(mockRedis.expire).toHaveBeenCalled();
132104

133-
expect(result).toEqual({
134-
rateLimited: false,
135-
requestCount: 0,
136-
rateLimit: 0,
137-
});
138-
});
105+
mockRedis.expire.mockClear();
139106

140-
it("should handle redis get failure gracefully", async () => {
141-
mockRedis.get.mockRejectedValue(new Error("Redis connection error"));
142-
143-
const result = await rateLimit({
107+
// Second case: current second already has requests
108+
mockRedis.mget.mockResolvedValueOnce(["5", ...Array(9).fill("5")]);
109+
await rateLimit({
144110
team: validTeamResponse,
145-
limitPerSecond: 5,
111+
limitPerSecond: 10,
146112
serviceConfig: validServiceConfig,
147113
redis: mockRedis,
148114
});
149-
150-
expect(result).toEqual({
151-
rateLimited: false,
152-
requestCount: 1,
153-
rateLimit: 50,
154-
});
115+
expect(mockRedis.expire).not.toHaveBeenCalled();
155116
});
156117

157-
it("should handle zero requests correctly", async () => {
158-
mockRedis.get.mockResolvedValue("0");
159-
118+
it("should increment by the amount provided", async () => {
119+
mockRedis.mget.mockResolvedValueOnce(["5"]);
160120
const result = await rateLimit({
161121
team: validTeamResponse,
162-
limitPerSecond: 5,
122+
limitPerSecond: 10,
163123
serviceConfig: validServiceConfig,
164124
redis: mockRedis,
125+
increment: 3,
165126
});
166-
167-
expect(result).toEqual({
168-
rateLimited: false,
169-
requestCount: 1,
170-
rateLimit: 50,
171-
});
172-
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.any(String), 1);
173-
});
174-
175-
it("should handle null response from redis", async () => {
176-
mockRedis.get.mockResolvedValue(null);
177-
178-
const result = await rateLimit({
179-
team: validTeamResponse,
180-
limitPerSecond: 5,
181-
serviceConfig: validServiceConfig,
182-
redis: mockRedis,
183-
});
184-
185-
expect(result).toEqual({
186-
rateLimited: false,
187-
requestCount: 1,
188-
rateLimit: 50,
189-
});
190-
});
191-
192-
it("should handle very low sample rates", async () => {
193-
mockRedis.get.mockResolvedValue("100");
194-
vi.spyOn(global.Math, "random").mockReturnValue(0.001);
195-
196-
const result = await rateLimit({
197-
team: validTeamResponse,
198-
limitPerSecond: 5,
199-
serviceConfig: validServiceConfig,
200-
redis: mockRedis,
201-
sampleRate: 0.01,
202-
});
203-
204-
expect(result).toEqual({
205-
rateLimited: true,
206-
requestCount: 100,
207-
rateLimit: 0.5,
208-
status: 429,
209-
errorMessage: expect.any(String),
210-
errorCode: "RATE_LIMIT_EXCEEDED",
211-
});
212-
});
213-
214-
it("should handle multiple concurrent requests with redis lag", async () => {
215-
// Mock initial state
216-
mockRedis.get.mockResolvedValue("0");
217-
218-
// Mock redis.set to have 100ms delay
219-
mockRedis.incrby.mockImplementation(
220-
() =>
221-
new Promise((resolve) => {
222-
setTimeout(() => resolve(1), 100);
223-
}),
224-
);
225-
226-
// Make 3 concurrent requests
227-
const requests = Promise.all([
228-
rateLimit({
229-
team: validTeamResponse,
230-
limitPerSecond: 5,
231-
serviceConfig: validServiceConfig,
232-
redis: mockRedis,
233-
}),
234-
rateLimit({
235-
team: validTeamResponse,
236-
limitPerSecond: 5,
237-
serviceConfig: validServiceConfig,
238-
redis: mockRedis,
239-
}),
240-
rateLimit({
241-
team: validTeamResponse,
242-
limitPerSecond: 5,
243-
serviceConfig: validServiceConfig,
244-
redis: mockRedis,
245-
}),
246-
]);
247-
248-
const results = await requests;
249-
// All requests should succeed since they all see initial count of 0
250-
for (const result of results) {
251-
expect(result).toEqual({
252-
rateLimited: false,
253-
requestCount: 1,
254-
rateLimit: 50,
255-
});
256-
}
257-
258-
// Redis set should be called 3 times
259-
expect(mockRedis.incrby).toHaveBeenCalledTimes(3);
260-
});
261-
262-
it("should handle custom increment values", async () => {
263-
// Mock initial state
264-
mockRedis.get.mockResolvedValue("5");
265-
mockRedis.incrby.mockResolvedValue(10);
266-
267-
const result = await rateLimit({
268-
team: validTeamResponse,
269-
limitPerSecond: 20,
270-
serviceConfig: validServiceConfig,
271-
redis: mockRedis,
272-
increment: 5,
273-
});
274-
275-
expect(result).toEqual({
276-
rateLimited: false,
277-
requestCount: 10,
278-
rateLimit: 200,
279-
});
280-
281-
// Verify redis was called with correct increment
282-
expect(mockRedis.incrby).toHaveBeenCalledWith(
283-
expect.stringContaining("rate-limit"),
284-
5,
285-
);
127+
expect(mockRedis.incrby).toHaveBeenCalledWith(expect.anything(), 3);
128+
expect(result.requestCount).toBe(8);
286129
});
287130
});

0 commit comments

Comments
 (0)