Skip to content

Commit a8b1b99

Browse files
authored
DX-2254: add global dynamic limit (#147)
* feat: add global dynamic limit * fix: receive false to disable dynamic limit * fix: make prefix required and define a constant * fix: remove dynamic limit from cached fixed window * fix: build error * fix: test * fix: add extensive dynamic tests
1 parent 8589adb commit a8b1b99

File tree

9 files changed

+602
-82
lines changed

9 files changed

+602
-82
lines changed

src/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Constants used throughout the ratelimit library
3+
*/
4+
5+
/**
6+
* Suffix for the global dynamic limit key in Redis
7+
* Full key format: `${prefix}:dynamic:global`
8+
*/
9+
export const DYNAMIC_LIMIT_KEY_SUFFIX = ":dynamic:global";
10+
11+
/**
12+
* Default prefix for Redis keys
13+
*/
14+
export const DEFAULT_PREFIX = "@upstash/ratelimit";

src/dynamic-limits.test.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { Redis } from "@upstash/redis";
2+
import { describe, expect, test } from "bun:test";
3+
import crypto from "node:crypto";
4+
import { Ratelimit } from "./index";
5+
import type { Algorithm, RatelimitResponseType, RegionContext } from "./types";
6+
7+
const redis = Redis.fromEnv();
8+
9+
type ExpectedResult = {
10+
/**
11+
* Expected limit value in response
12+
*/
13+
limit: number;
14+
/**
15+
* Expected remaining tokens after consuming requestCount
16+
*/
17+
remaining: number;
18+
/**
19+
* Whether requests should succeed
20+
*/
21+
success: boolean;
22+
/**
23+
* Expected result from getDynamicLimit()
24+
*/
25+
dynamicLimit: number | null;
26+
};
27+
28+
type TestCase = {
29+
name: string;
30+
/**
31+
* Default limit configured in limiter
32+
*/
33+
defaultLimit: number;
34+
/**
35+
* Number of requests to make
36+
*/
37+
requestCount: number;
38+
/**
39+
* Whether dynamicLimits is enabled
40+
*/
41+
dynamicLimitsEnabled: boolean;
42+
/**
43+
* Dynamic limit to set (null means don't call setDynamicLimit)
44+
*/
45+
setDynamicLimit: number | null;
46+
/**
47+
* Expected results after consuming requestCount requests
48+
*/
49+
expected: ExpectedResult;
50+
};
51+
52+
const testCases: TestCase[] = [
53+
// Case 1: dynamicLimits: true, setDynamicLimit called
54+
{
55+
name: "dynamicLimits enabled, dynamic limit set (lower than default)",
56+
defaultLimit: 10,
57+
requestCount: 3,
58+
dynamicLimitsEnabled: true,
59+
setDynamicLimit: 3,
60+
expected: {
61+
limit: 3,
62+
remaining: 0,
63+
success: true, // 3 requests with limit 3 should succeed
64+
dynamicLimit: 3,
65+
},
66+
},
67+
{
68+
name: "dynamicLimits enabled, dynamic limit set (exceeds limit)",
69+
defaultLimit: 10,
70+
requestCount: 5,
71+
dynamicLimitsEnabled: true,
72+
setDynamicLimit: 3,
73+
expected: {
74+
limit: 3,
75+
remaining: 0,
76+
success: false, // 5 requests with limit 3 should fail
77+
dynamicLimit: 3,
78+
},
79+
},
80+
// Case 2: dynamicLimits: true, setDynamicLimit not called
81+
{
82+
name: "dynamicLimits enabled, no dynamic limit set (uses default)",
83+
defaultLimit: 10,
84+
requestCount: 5,
85+
dynamicLimitsEnabled: true,
86+
setDynamicLimit: null,
87+
expected: {
88+
limit: 10,
89+
remaining: 5,
90+
success: true, // 5 requests with default limit 10 should succeed
91+
dynamicLimit: null,
92+
},
93+
},
94+
// Case 3: dynamicLimits: false, setDynamicLimit not called
95+
{
96+
name: "dynamicLimits disabled, no dynamic limit (uses default)",
97+
defaultLimit: 10,
98+
requestCount: 5,
99+
dynamicLimitsEnabled: false,
100+
setDynamicLimit: null,
101+
expected: {
102+
limit: 10,
103+
remaining: 5,
104+
success: true, // 5 requests with default limit 10 should succeed
105+
dynamicLimit: null,
106+
},
107+
},
108+
];
109+
110+
function run(
111+
limiterName: string,
112+
limiterBuilder: (limit: number) => Algorithm<RegionContext>
113+
) {
114+
describe(limiterName, () => {
115+
// Test error cases
116+
test("should throw error when setDynamicLimit called with dynamicLimits disabled", async () => {
117+
const ratelimit = new Ratelimit({
118+
redis,
119+
limiter: limiterBuilder(10),
120+
prefix: crypto.randomUUID(),
121+
});
122+
123+
await expect(async () => {
124+
await ratelimit.setDynamicLimit({ limit: 100 });
125+
}).toThrow("dynamicLimits must be enabled");
126+
});
127+
128+
test("should throw error when getDynamicLimit called with dynamicLimits disabled", async () => {
129+
const ratelimit = new Ratelimit({
130+
redis,
131+
limiter: limiterBuilder(10),
132+
prefix: crypto.randomUUID(),
133+
});
134+
135+
await expect(async () => {
136+
await ratelimit.getDynamicLimit();
137+
}).toThrow("dynamicLimits must be enabled");
138+
});
139+
140+
// Test all cases
141+
for (const tc of testCases) {
142+
test(tc.name, async () => {
143+
const prefix = crypto.randomUUID();
144+
const ratelimit = new Ratelimit({
145+
redis,
146+
limiter: limiterBuilder(tc.defaultLimit),
147+
prefix,
148+
dynamicLimits: tc.dynamicLimitsEnabled,
149+
ephemeralCache: false, // Disable cache for accurate testing
150+
});
151+
152+
const identifier = crypto.randomUUID();
153+
154+
// Set dynamic limit if specified
155+
if (tc.setDynamicLimit !== null) {
156+
await ratelimit.setDynamicLimit({ limit: tc.setDynamicLimit });
157+
}
158+
159+
// Verify getDynamicLimit before making requests
160+
if (tc.dynamicLimitsEnabled) {
161+
const { dynamicLimit } = await ratelimit.getDynamicLimit();
162+
expect(dynamicLimit).toBe(tc.expected.dynamicLimit);
163+
}
164+
165+
// Make requests using rate parameter
166+
const result = await ratelimit.limit(identifier, { rate: tc.requestCount });
167+
168+
// Verify result
169+
expect(result.success).toBe(tc.expected.success);
170+
expect(result.limit).toBe(tc.expected.limit);
171+
expect(result.remaining).toBe(tc.expected.remaining);
172+
173+
// Verify getDynamicLimit after request
174+
if (tc.dynamicLimitsEnabled) {
175+
const { dynamicLimit } = await ratelimit.getDynamicLimit();
176+
expect(dynamicLimit).toBe(tc.expected.dynamicLimit);
177+
}
178+
179+
// Verify getRemaining after request
180+
const finalRemaining = await ratelimit.getRemaining(identifier);
181+
expect(finalRemaining.limit).toBe(tc.expected.limit);
182+
expect(finalRemaining.remaining).toBe(tc.expected.remaining);
183+
});
184+
}
185+
186+
// Test ephemeral cache behavior with dynamic limits
187+
const cacheTestCases = [
188+
{
189+
name: "with cache enabled - should block via cache after dynamic limit is removed",
190+
ephemeralCache: undefined, // undefined means cache is enabled by default
191+
expectedSecondCallSuccess: false,
192+
expectedSecondCallReason: "cacheBlock" as RatelimitResponseType | undefined,
193+
},
194+
{
195+
name: "with cache disabled - behavior after dynamic limit is removed",
196+
ephemeralCache: false as const,
197+
expectedSecondCallSuccess: undefined, // Will vary by algorithm
198+
expectedSecondCallReason: undefined as RatelimitResponseType | undefined,
199+
},
200+
] as const;
201+
202+
for (const cacheTest of cacheTestCases) {
203+
test(cacheTest.name, async () => {
204+
const prefix = crypto.randomUUID();
205+
const ratelimit = new Ratelimit({
206+
redis,
207+
limiter: limiterBuilder(10), // default limit: 10
208+
prefix,
209+
dynamicLimits: true,
210+
ephemeralCache: cacheTest.ephemeralCache,
211+
});
212+
213+
const identifier = crypto.randomUUID();
214+
215+
// Set dynamic limit to 3 (lower than default 10)
216+
await ratelimit.setDynamicLimit({ limit: 3 });
217+
218+
// Make a request with rate=5, which exceeds dynamic limit (3) but not default (10)
219+
const firstResult = await ratelimit.limit(identifier, { rate: 5 });
220+
221+
// First call should fail due to dynamic limit
222+
expect(firstResult.success).toBe(false);
223+
expect(firstResult.limit).toBe(3);
224+
expect(firstResult.remaining).toBe(0);
225+
226+
// Remove the dynamic limit
227+
await ratelimit.setDynamicLimit({ limit: false });
228+
229+
// Verify dynamic limit is removed
230+
const { dynamicLimit } = await ratelimit.getDynamicLimit();
231+
expect(dynamicLimit).toBeNull();
232+
233+
// Second call behavior depends on cache setting
234+
const secondResult = await ratelimit.limit(identifier);
235+
if (cacheTest.expectedSecondCallReason) {
236+
expect(secondResult.reason).toBe(cacheTest.expectedSecondCallReason);
237+
} else {
238+
expect(secondResult.reason).toBeUndefined();
239+
}
240+
241+
if (cacheTest.expectedSecondCallSuccess === undefined) {
242+
// When cache is disabled, behavior differs by algorithm
243+
if (limiterName === "tokenBucket") {
244+
// tokenBucket still fails because it has 0 tokens stored and needs refill time
245+
expect(secondResult.success).toBe(false);
246+
expect(secondResult.limit).toBe(10);
247+
expect(secondResult.remaining).toBe(0);
248+
} else {
249+
// fixedWindow/slidingWindow succeed because they track used tokens
250+
expect(secondResult.success).toBe(true);
251+
expect(secondResult.limit).toBe(10);
252+
expect(secondResult.remaining).toBe(4); // 10 - 5 (first) - 1 (second) = 4
253+
}
254+
} else {
255+
expect(secondResult.success).toBe(cacheTest.expectedSecondCallSuccess);
256+
}
257+
258+
if (cacheTest.expectedSecondCallSuccess === false && cacheTest.expectedSecondCallReason === "cacheBlock") {
259+
// Cache block case - no other checks needed
260+
}
261+
});
262+
}
263+
});
264+
}
265+
266+
describe("Dynamic Limits", () => {
267+
run("fixedWindow", (limit) => Ratelimit.fixedWindow(limit, "1000 s"));
268+
run("slidingWindow", (limit) => Ratelimit.slidingWindow(limit, "1000 s"));
269+
run("tokenBucket", (limit) => Ratelimit.tokenBucket(limit, "1000 s", limit));
270+
});

src/hash.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Redis } from "@upstash/redis";
22
import { describe, test } from "bun:test";
33
import { safeEval } from "./hash";
44
import { SCRIPTS } from "./lua-scripts/hash";
5+
import { DEFAULT_PREFIX } from "./constants";
56

67
const redis = Redis.fromEnv();
78

@@ -14,11 +15,12 @@ describe("should set hash correctly", () => {
1415

1516
await safeEval(
1617
{
17-
redis
18+
redis,
19+
prefix: DEFAULT_PREFIX
1820
},
1921
SCRIPTS.singleRegion.fixedWindow.limit,
20-
["id"],
21-
[10, 1]
22+
["id", ""],
23+
[10, 10, 1]
2224
)
2325
})
2426
})

src/lua-scripts/hash.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,31 +26,31 @@ export const SCRIPTS: {
2626
fixedWindow: {
2727
limit: {
2828
script: Single.fixedWindowLimitScript,
29-
hash: "b13943e359636db027ad280f1def143f02158c13"
29+
hash: "472e55443b62f60d0991028456c57815a387066d"
3030
},
3131
getRemaining: {
3232
script: Single.fixedWindowRemainingTokensScript,
33-
hash: "8c4c341934502aee132643ffbe58ead3450e5208"
33+
hash: "40515c9dd0a08f8584f5f9b593935f6a87c1c1c3"
3434
},
3535
},
3636
slidingWindow: {
3737
limit: {
3838
script: Single.slidingWindowLimitScript,
39-
hash: "9b7842963bd73721f1a3011650c23c0010848ee3"
39+
hash: "977fb636fb5ceb7e98a96d1b3a1272ba018efdae"
4040
},
4141
getRemaining: {
4242
script: Single.slidingWindowRemainingTokensScript,
43-
hash: "65a73ac5a05bf9712903bc304b77268980c1c417"
43+
hash: "ee3a3265fad822f83acad23f8a1e2f5c0b156b03"
4444
},
4545
},
4646
tokenBucket: {
4747
limit: {
4848
script: Single.tokenBucketLimitScript,
49-
hash: "d1f857ebbdaeca90ccd2cd4eada61d7c8e5db1ca"
49+
hash: "b35c5bc0b7fdae7dd0573d4529911cabaf9d1d89"
5050
},
5151
getRemaining: {
5252
script: Single.tokenBucketRemainingTokensScript,
53-
hash: "a15be2bb1db2a15f7c82db06146f9d08983900d0"
53+
hash: "deb03663e8af5a968deee895dd081be553d2611b"
5454
},
5555
},
5656
cachedFixedWindow: {

0 commit comments

Comments
 (0)