Skip to content

Commit 544454b

Browse files
authored
DX-2218: Refactor rate limit test cases and update request timing in TestHarness (#142)
* Refactor rate limit test cases and update request timing in TestHarness * fix: use rate in cachedFixedWindow correctly * chore: update node version to 20 in workflow configurations * fix: change wrangler publish to wrangler deploy in workflow * fix: update test cases to use a single limit value of 16 * fix: update package.json dependencies to fix nextjs deployed errors * fix: add delays before and after resetting tokens in tests to fix flaky tests
1 parent c12bee3 commit 544454b

File tree

10 files changed

+100
-56
lines changed

10 files changed

+100
-56
lines changed

.github/workflows/tests.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ jobs:
8989
- name: Setup nodejs
9090
uses: actions/setup-node@v3
9191
with:
92-
node-version: 18
92+
node-version: 20
9393

9494
- name: Setup Bun
9595
uses: oven-sh/setup-bun@v1
@@ -114,7 +114,7 @@ jobs:
114114
working-directory: examples/cloudflare-workers
115115

116116
- name: Deploy
117-
run: wrangler publish
117+
run: wrangler deploy
118118
working-directory: examples/cloudflare-workers
119119
env:
120120
CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}}
@@ -238,7 +238,7 @@ jobs:
238238
- name: Setup Node
239239
uses: actions/setup-node@v2
240240
with:
241-
node-version: 18
241+
node-version: 20
242242

243243
- name: Set package version
244244
run: echo $(jq --arg v "${{ steps.version.outputs.version }}" '(.version) = $v' package.json) > package.json

examples/nextjs/bun.lockb

-358 Bytes
Binary file not shown.

examples/nextjs/next-env.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
/// <reference types="next/navigation-types/compat/navigation" />
34

45
// NOTE: This file should not be edited
5-
// see https://nextjs.org/docs/basic-features/typescript for more information.
6+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

examples/nextjs/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12-
"@upstash/ratelimit": "latest",
12+
"@upstash/ratelimit": "^2.0.6",
1313
"@upstash/redis": "latest",
1414
"@vercel/functions": "^1.0.2",
1515
"next": "14.2.23",
@@ -19,8 +19,8 @@
1919
"devDependencies": {
2020
"@types/bun": "^1.1.10",
2121
"@types/node": "^20",
22-
"@types/react": "^18",
23-
"@types/react-dom": "^18",
22+
"@types/react": "^19.2.2",
23+
"@types/react-dom": "^19",
2424
"bun-types": "latest",
2525
"eslint": "^8",
2626
"eslint-config-next": "14.2.3",

src/cache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ export class Cache implements EphemeralCache {
3434
return this.cache.get(key) || null;
3535
}
3636

37-
public incr(key: string): number {
37+
public incr(key: string, incrementAmount: number = 1): number {
3838
let value = this.cache.get(key) ?? 0;
39-
value += 1;
39+
value += incrementAmount;
4040
this.cache.set(key, value);
4141
return value;
4242
}

src/ratelimit.test.ts

Lines changed: 83 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,75 +11,101 @@ import { TestHarness } from "./test_utils";
1111
import type { Context, MultiRegionContext, RegionContext } from "./types";
1212

1313
type TestCase = {
14-
// requests per second
15-
rps: number;
1614
/**
17-
* Multiplier for rate
18-
*
19-
* rate = 10, load = 0.5 -> attack rate will be 5
15+
* Limit allowed during window
16+
*/
17+
limit: number;
18+
/**
19+
* Request load
20+
*
21+
* E.g., 0.5 means 50% of the limit in each window will be consumed,
22+
* so all requests will succeed (assuming rate=1)
23+
*
24+
* E.g., 2 means 200% of the limit in each window will be consumed,
25+
* so half of the requests will be rejected (assuming rate=1)
2026
*/
2127
load: number;
2228
/**
23-
* rate at which the tokens will be added or consumed, default should be 1
24-
* @default 1
29+
* rate at which the tokens will be added or consumed
2530
*/
26-
rate?: number;
31+
rate: number;
2732
};
28-
const attackDuration = 10;
29-
const window = 5;
33+
const attackDuration = 8;
34+
const window = 4;
3035
const windowString: Duration = `${window} s`;
3136

3237
const testcases: TestCase[] = [];
3338

34-
for (const rps of [10, 100]) {
35-
for (const load of [0.5, 0.7]) {
36-
for (const rate of [undefined, 10]) {
37-
testcases.push({ load, rps, rate });
39+
for (const limit of [16]) {
40+
for (const load of [0.8, 1.6]) {
41+
for (const rate of [1, 3]) {
42+
testcases.push({ load, limit, rate });
3843
}
3944
}
4045
}
4146

42-
function run<TContext extends Context>(builder: (tc: TestCase) => Ratelimit<TContext>) {
47+
function run<TContext extends Context>(
48+
builder: (tc: TestCase) => Ratelimit<TContext>
49+
) {
4350
for (const tc of testcases) {
44-
const name = `${tc.rps.toString().padStart(4, " ")}/s - Load: ${(tc.load * 100)
45-
.toString()
46-
.padStart(3, " ")}% -> Sending ${(tc.rps * tc.load)
47-
.toString()
48-
.padStart(4, " ")}req/s at the rate of ${tc.rate ?? 1}`;
49-
const ratelimit = builder(tc);
51+
52+
const windowCount = attackDuration / window;
53+
/**
54+
* Total number of requests sent during the attack
55+
*/
56+
const attackRequestCount = windowCount * tc.limit * tc.load;
57+
/**
58+
* Number of requests the simulated attacker shall attempt
59+
*/
60+
const attackRequestPerSecond = attackRequestCount / attackDuration;
61+
/**
62+
* Maximum number of requests that can be allowed per second
63+
*/
64+
const maxSuccessRequestCount = windowCount * tc.limit / tc.rate;
65+
/**
66+
* Number of successful requests expected during the attack
67+
*/
68+
const expectedSuccessRequestCount = Number.parseFloat(Math.min(maxSuccessRequestCount, attackRequestCount).toFixed(2));
5069

5170
const limits = {
52-
lte: ((attackDuration * tc.rps * (tc.rate ?? 1)) / window) * 1.5,
53-
gte: ((attackDuration * tc.rps) / window) * 0.5,
71+
lte: Number.parseFloat((expectedSuccessRequestCount * 1.5).toFixed(2)),
72+
gte: Number.parseFloat((expectedSuccessRequestCount * 0.5).toFixed(2)),
5473
};
74+
75+
const name = `${tc.limit} Limit, ${tc.load * 100}% Load, ${attackRequestPerSecond} req/s (with rate=${tc.rate})`;
76+
const range = `Range: ${limits.gte} - ${limits.lte} Success`
77+
78+
const ratelimit = builder(tc);
79+
5580
describe(name, () => {
5681
test(
57-
`should be within ${limits.gte} - ${limits.lte}`,
82+
range,
5883
async () => {
59-
log(name);
84+
log();
85+
log(` Config: ${name}`);
86+
log(` ${range} (Expected: ${expectedSuccessRequestCount})`);
6087
const harness = new TestHarness(ratelimit);
61-
await harness.attack(tc.rps * tc.load, attackDuration, tc.rate).catch((error) => {
62-
console.error(error);
63-
});
88+
await harness
89+
.attack(attackRequestPerSecond, attackDuration, tc.rate)
90+
.catch((error) => {
91+
console.error(error);
92+
});
6493
log(
65-
"success:",
66-
harness.metrics.success,
67-
", blocked:",
68-
harness.metrics.rejected,
69-
"out of:",
70-
harness.metrics.requests,
94+
` Result: success: ${harness.metrics.success}, blocked: ${harness.metrics.rejected} (out of: ${harness.metrics.requests})`
7195
);
7296

7397
expect(harness.metrics.success).toBeLessThanOrEqual(limits.lte);
7498
expect(harness.metrics.success).toBeGreaterThanOrEqual(limits.gte);
7599
},
76-
attackDuration * 1000 * 4,
100+
attackDuration * 1000 * 4
77101
);
78102
});
79103
}
80104
}
81105

82-
function newMultiRegion(limiter: Algorithm<MultiRegionContext>): Ratelimit<MultiRegionContext> {
106+
function newMultiRegion(
107+
limiter: Algorithm<MultiRegionContext>
108+
): Ratelimit<MultiRegionContext> {
83109
// eslint-disable-next-line unicorn/consistent-function-scoping
84110
function ensureEnv(key: string): string {
85111
const value = process.env[key];
@@ -109,7 +135,9 @@ function newMultiRegion(limiter: Algorithm<MultiRegionContext>): Ratelimit<Multi
109135
});
110136
}
111137

112-
function newRegion(limiter: Algorithm<RegionContext>): Ratelimit<RegionContext> {
138+
function newRegion(
139+
limiter: Algorithm<RegionContext>
140+
): Ratelimit<RegionContext> {
113141
return new RegionRatelimit({
114142
prefix: crypto.randomUUID(),
115143
redis: Redis.fromEnv(),
@@ -146,32 +174,44 @@ describe("timeout", () => {
146174

147175
describe("fixedWindow", () => {
148176
describe("region", () =>
149-
run((tc) => newRegion(RegionRatelimit.fixedWindow(tc.rps * (tc.rate ?? 1), windowString))));
177+
run((tc) =>
178+
newRegion(RegionRatelimit.fixedWindow(tc.limit, windowString))
179+
));
150180

151181
describe("multiRegion", () =>
152182
run((tc) =>
153-
newMultiRegion(MultiRegionRatelimit.fixedWindow(tc.rps * (tc.rate ?? 1), windowString)),
183+
newMultiRegion(
184+
MultiRegionRatelimit.fixedWindow(tc.limit, windowString)
185+
)
154186
));
155187
});
156188
describe("slidingWindow", () => {
157189
describe("region", () =>
158-
run((tc) => newRegion(RegionRatelimit.slidingWindow(tc.rps * (tc.rate ?? 1), windowString))));
190+
run((tc) =>
191+
newRegion(RegionRatelimit.slidingWindow(tc.limit, windowString))
192+
));
159193
describe("multiRegion", () =>
160194
run((tc) =>
161-
newMultiRegion(MultiRegionRatelimit.slidingWindow(tc.rps * (tc.rate ?? 1), windowString)),
195+
newMultiRegion(
196+
MultiRegionRatelimit.slidingWindow(tc.limit, windowString)
197+
)
162198
));
163199
});
164200

165201
describe("tokenBucket", () => {
166202
describe("region", () =>
167203
run((tc) =>
168-
newRegion(RegionRatelimit.tokenBucket(tc.rps, windowString, tc.rps * (tc.rate ?? 1))),
204+
newRegion(
205+
RegionRatelimit.tokenBucket(tc.limit, windowString, tc.limit)
206+
)
169207
));
170208
});
171209

172210
describe("cachedFixedWindow", () => {
173211
describe("region", () =>
174212
run((tc) =>
175-
newRegion(RegionRatelimit.cachedFixedWindow(tc.rps * (tc.rate ?? 1), windowString)),
213+
newRegion(
214+
RegionRatelimit.cachedFixedWindow(tc.limit, windowString)
215+
)
176216
));
177217
});

src/resetUsedTokens.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ function run<TContext extends Context>(builder: Ratelimit<TContext>) {
2323
}
2424
await Promise.all(pendings)
2525

26+
await new Promise(r => setTimeout(r, 300));
27+
2628
// reset tokens
2729
await builder.resetUsedTokens(id);
30+
await new Promise(r => setTimeout(r, 300));
2831
const { remaining } = await builder.getRemaining(id);
2932
expect(remaining).toBe(limit);
3033
}, 10_000);

src/single.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { Algorithm, RegionContext } from "./types";
99
import type { Redis as RedisCore } from "./types";
1010

1111
// Fix for https://github.com/upstash/ratelimit-js/issues/125
12-
type Redis = Pick<RedisCore, "get" | "set">
12+
type Redis = Pick<RedisCore, "evalsha" | "get" | "set">
1313

1414
export type RegionRatelimitConfig = {
1515
/**
@@ -490,7 +490,7 @@ export class RegionRatelimit extends Ratelimit<RegionContext> {
490490

491491
const hit = typeof ctx.cache.get(key) === "number";
492492
if (hit) {
493-
const cachedTokensAfterUpdate = ctx.cache.incr(key);
493+
const cachedTokensAfterUpdate = ctx.cache.incr(key, incrementBy);
494494
const success = cachedTokensAfterUpdate < tokens;
495495

496496
const pending = success

src/test_utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class TestHarness<TContext extends Context> {
4646
return res;
4747
}),
4848
);
49-
await new Promise((r) => setTimeout(r, 500 / rps));
49+
await new Promise((r) => setTimeout(r, 1000 / rps));
5050
}
5151
}
5252

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export type EphemeralCache = {
1111
set: (key: string, value: number) => void;
1212
get: (key: string) => number | null;
1313

14-
incr: (key: string) => number;
14+
incr: (key: string, incrementAmount?: number) => number;
1515

1616
pop: (key: string) => void;
1717
empty: () => void;

0 commit comments

Comments
 (0)