Skip to content

Commit 69ec6e0

Browse files
authored
Expose rateLimitSlidingWindow strategy for low-level usage (#6815)
1 parent 21cc01a commit 69ec6e0

File tree

6 files changed

+120
-63
lines changed

6 files changed

+120
-63
lines changed

.changeset/tall-llamas-dress.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@thirdweb-dev/service-utils": patch
3+
---
4+
5+
expose `rateLimitSlidingWindow` strategy directly for more low-level usage
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import type { CoreServiceConfig, TeamResponse } from "../api.js";
2+
import type { IRedis } from "./strategies/shared.js";
3+
import { rateLimitSlidingWindow } from "./strategies/sliding-window.js";
24
import type { RateLimitResult } from "./types.js";
35

46
const SLIDING_WINDOW_SECONDS = 10;
57

6-
// Redis interface compatible with ioredis (Node) and upstash (Cloudflare Workers).
7-
type IRedis = {
8-
incrby(key: string, value: number): Promise<number>;
9-
mget(keys: string[]): Promise<(string | null)[]>;
10-
expire(key: string, seconds: number): Promise<number>;
11-
};
12-
138
/**
149
* Increments the request count for this team and returns whether the team has hit their rate limit.
1510
* Uses a sliding 10 second window.
@@ -30,68 +25,29 @@ export async function rateLimit(args: {
3025
const { team, limitPerSecond, serviceConfig, redis, increment = 1 } = args;
3126
const { serviceScope } = serviceConfig;
3227

33-
if (limitPerSecond === 0) {
34-
// No rate limit is provided. Assume the request is not rate limited.
35-
return {
36-
rateLimited: false,
37-
requestCount: 0,
38-
rateLimit: 0,
39-
};
40-
}
41-
42-
// Enforce rate limit: sum the total requests in the last `SLIDING_WINDOW_SECONDS` seconds.
43-
const currentSecond = Math.floor(Date.now() / 1000);
44-
const keys = Array.from({ length: SLIDING_WINDOW_SECONDS }, (_, i) =>
45-
getRequestCountAtSecondCacheKey(serviceScope, team.id, currentSecond - i),
46-
);
47-
const counts = await redis.mget(keys);
48-
const totalCount = counts.reduce(
49-
(sum, count) => sum + (count ? Number.parseInt(count) : 0),
50-
0,
51-
);
28+
const rateLimitResult = await rateLimitSlidingWindow({
29+
redis,
30+
limitPerSecond,
31+
key: `rate-limit:${serviceScope}:${team.id}`,
32+
increment,
33+
windowSeconds: SLIDING_WINDOW_SECONDS,
34+
});
5235

53-
const limitPerWindow = limitPerSecond * SLIDING_WINDOW_SECONDS;
54-
55-
if (totalCount > limitPerWindow) {
36+
// if the request is rate limited, return the rate limit result.
37+
if (rateLimitResult.rateLimited) {
5638
return {
5739
rateLimited: true,
58-
requestCount: totalCount,
59-
rateLimit: limitPerWindow,
40+
requestCount: rateLimitResult.requestCount,
41+
rateLimit: rateLimitResult.rateLimit,
6042
status: 429,
61-
errorMessage: `You've exceeded your ${serviceScope} rate limit at ${limitPerSecond} reqs/sec. Please upgrade your plan to get higher rate limits.`,
43+
errorMessage: `You've exceeded your ${serviceScope} rate limit at ${limitPerSecond} requests per second. Please upgrade your plan to increase your limits: https://thirdweb.com/team/${team.slug}/~/settings/billing`,
6244
errorCode: "RATE_LIMIT_EXCEEDED",
6345
};
6446
}
65-
66-
// Non-blocking: increment the request count for the current second.
67-
(async () => {
68-
try {
69-
const key = getRequestCountAtSecondCacheKey(
70-
serviceScope,
71-
team.id,
72-
currentSecond,
73-
);
74-
await redis.incrby(key, increment);
75-
// If this is the first time setting this key, expire it after the sliding window is past.
76-
if (counts[0] === null) {
77-
await redis.expire(key, SLIDING_WINDOW_SECONDS + 1);
78-
}
79-
} catch (error) {
80-
console.error("Error updating rate limit key:", error);
81-
}
82-
})();
83-
47+
// otherwise, the request is not rate limited.
8448
return {
8549
rateLimited: false,
86-
requestCount: totalCount + increment,
87-
rateLimit: limitPerWindow,
50+
requestCount: rateLimitResult.requestCount,
51+
rateLimit: rateLimitResult.rateLimit,
8852
};
8953
}
90-
91-
function getRequestCountAtSecondCacheKey(
92-
serviceScope: CoreServiceConfig["serviceScope"],
93-
teamId: string,
94-
second: number,
95-
) {
96-
return `rate-limit:${serviceScope}:${teamId}:${second}`;
97-
}

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("rateLimit", () => {
3131

3232
expect(result).toEqual({
3333
rateLimited: false,
34-
requestCount: 0,
34+
requestCount: 1,
3535
rateLimit: 0,
3636
});
3737
expect(mockRedis.mget).not.toHaveBeenCalled();
@@ -62,7 +62,7 @@ describe("rateLimit", () => {
6262
// Verify correct keys are checked
6363
const expectedKeys = Array.from(
6464
{ length: SLIDING_WINDOW_SECONDS },
65-
(_, i) => `rate-limit:storage:1:${currentSecond - i}`,
65+
(_, i) => `rate-limit:storage:1:s_${currentSecond - i}`,
6666
);
6767
expect(mockRedis.mget).toHaveBeenCalledWith(expectedKeys);
6868

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Redis interface compatible with ioredis (Node) and upstash (Cloudflare Workers).
2+
export type IRedis = {
3+
incrby(key: string, value: number): Promise<number>;
4+
mget(keys: string[]): Promise<(string | null)[]>;
5+
expire(key: string, seconds: number): Promise<number>;
6+
};
7+
8+
export type CoreRateLimitResult = {
9+
rateLimited: boolean;
10+
requestCount: number;
11+
rateLimit: number;
12+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { CoreRateLimitResult, IRedis } from "./shared.js";
2+
3+
type RateLimitSlidingWindowOptions = {
4+
redis: IRedis;
5+
limitPerSecond: number;
6+
key: string;
7+
/**
8+
* The number of requests to increment by.
9+
* @default 1
10+
*/
11+
increment?: number;
12+
/**
13+
* The number of seconds to look back for the sliding window.
14+
* @default 10
15+
*/
16+
windowSeconds?: number;
17+
};
18+
19+
export async function rateLimitSlidingWindow(
20+
options: RateLimitSlidingWindowOptions,
21+
): Promise<CoreRateLimitResult> {
22+
const WINDOW_SIZE = options.windowSeconds || 10;
23+
const INCREMENT_BY = options.increment || 1;
24+
25+
// No rate limit is provided. Assume the request is not rate limited.
26+
if (options.limitPerSecond <= 0) {
27+
return {
28+
rateLimited: false,
29+
requestCount: INCREMENT_BY,
30+
rateLimit: 0,
31+
};
32+
}
33+
34+
// Enforce rate limit: sum the total requests in the last `SLIDING_WINDOW_SECONDS` seconds.
35+
const currentSecond = Math.floor(Date.now() / 1000);
36+
const keys = Array.from({ length: WINDOW_SIZE }, (_, i) =>
37+
getRequestCountAtSecondCacheKey(options.key, currentSecond - i),
38+
);
39+
const counts = await options.redis.mget(keys);
40+
const totalCount = counts.reduce(
41+
(sum, count) => sum + (count ? Number.parseInt(count) : 0),
42+
0,
43+
);
44+
45+
const limitPerWindow = options.limitPerSecond * WINDOW_SIZE;
46+
47+
if (totalCount > limitPerWindow) {
48+
return {
49+
rateLimited: true,
50+
requestCount: totalCount,
51+
rateLimit: limitPerWindow,
52+
};
53+
}
54+
55+
// Non-blocking: increment the request count for the current second.
56+
(async () => {
57+
try {
58+
const incrKey = getRequestCountAtSecondCacheKey(
59+
options.key,
60+
currentSecond,
61+
);
62+
await options.redis.incrby(incrKey, INCREMENT_BY);
63+
// If this is the first time setting this key, expire it after the sliding window is past.
64+
if (counts[0] === null) {
65+
await options.redis.expire(incrKey, WINDOW_SIZE + 1);
66+
}
67+
} catch (error) {
68+
console.error("Error updating rate limit key:", error);
69+
}
70+
})();
71+
72+
return {
73+
rateLimited: false,
74+
requestCount: totalCount + INCREMENT_BY,
75+
rateLimit: limitPerWindow,
76+
};
77+
}
78+
79+
function getRequestCountAtSecondCacheKey(key: string, second: number) {
80+
return `${key}:s_${second}`;
81+
}

packages/service-utils/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ export {
2020
authorizeBundleId,
2121
authorizeDomain,
2222
} from "./core/authorize/client.js";
23+
24+
export { rateLimitSlidingWindow } from "./core/rateLimit/strategies/sliding-window.js";
25+
export { rateLimit } from "./core/rateLimit/index.js";

0 commit comments

Comments
 (0)