@@ -2,26 +2,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22import { validServiceConfig , validTeamResponse } from "../../mocks.js" ;
33import { rateLimit } from "./index.js" ;
44
5+ const SLIDING_WINDOW_SECONDS = 10 ;
6+
57const mockRedis = {
6- get : vi . fn ( ) ,
8+ mget : vi . fn ( ) ,
79 expire : vi . fn ( ) ,
810 incrby : vi . fn ( ) ,
911} ;
1012
1113describe ( "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