Skip to content

Commit ec099a7

Browse files
committed
chore: Update rate limit to sliding window (10s) (#6730)
Updates the rate limit to use a sliding window <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on updating the rate limiting functionality in the `rateLimit` function to utilize a sliding window approach instead of a fixed window, enhancing how requests are counted over time. ### Detailed summary - Renamed `RATE_LIMIT_WINDOW_SECONDS` to `SLIDING_WINDOW_SECONDS`. - Modified Redis interaction to use `mget` for batch fetching request counts. - Updated the logic to calculate total requests over the last 10 seconds. - Removed `sampleRate` handling and its related logic. - Changed error messages for exceeding rate limits. - Adjusted tests to align with the new sliding window logic. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 185d2f3 commit ec099a7

File tree

3 files changed

+112
-267
lines changed

3 files changed

+112
-267
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
Lines changed: 50 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,34 @@
11
import type { CoreServiceConfig, TeamResponse } from "../api.js";
22
import type { RateLimitResult } from "./types.js";
33

4-
const RATE_LIMIT_WINDOW_SECONDS = 10;
4+
const SLIDING_WINDOW_SECONDS = 10;
55

66
// Redis interface compatible with ioredis (Node) and upstash (Cloudflare Workers).
77
type IRedis = {
8-
get: (key: string) => Promise<string | null>;
9-
expire(key: string, seconds: number): Promise<number>;
108
incrby(key: string, value: number): Promise<number>;
9+
mget(keys: string[]): Promise<(string | null)[]>;
10+
expire(key: string, seconds: number): Promise<number>;
1111
};
1212

13+
/**
14+
* Increments the request count for this team and returns whether the team has hit their rate limit.
15+
* Uses a sliding 10 second window.
16+
* @param args
17+
* @returns
18+
*/
1319
export async function rateLimit(args: {
1420
team: TeamResponse;
1521
limitPerSecond: number;
1622
serviceConfig: CoreServiceConfig;
1723
redis: IRedis;
18-
/**
19-
* Sample requests to reduce load on Redis.
20-
* This scales down the request count and the rate limit threshold.
21-
* @default 1.0
22-
*/
23-
sampleRate?: number;
2424
/**
2525
* The number of requests to increment by.
2626
* @default 1
2727
*/
2828
increment?: number;
2929
}): Promise<RateLimitResult> {
30-
const {
31-
team,
32-
limitPerSecond,
33-
serviceConfig,
34-
redis,
35-
sampleRate = 1.0,
36-
increment = 1,
37-
} = args;
38-
39-
const shouldSampleRequest = Math.random() < sampleRate;
40-
if (!shouldSampleRequest) {
41-
return {
42-
rateLimited: false,
43-
requestCount: 0,
44-
rateLimit: 0,
45-
};
46-
}
30+
const { team, limitPerSecond, serviceConfig, redis, increment = 1 } = args;
31+
const { serviceScope } = serviceConfig;
4732

4833
if (limitPerSecond === 0) {
4934
// No rate limit is provided. Assume the request is not rate limited.
@@ -54,47 +39,59 @@ export async function rateLimit(args: {
5439
};
5540
}
5641

57-
const serviceScope = serviceConfig.serviceScope;
58-
59-
// Gets the 10-second window for the current timestamp.
60-
const timestampWindow =
61-
Math.floor(Date.now() / (1000 * RATE_LIMIT_WINDOW_SECONDS)) *
62-
RATE_LIMIT_WINDOW_SECONDS;
63-
const key = `rate-limit:${serviceScope}:${team.id}:${timestampWindow}`;
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+
);
6452

65-
// first read the request count from redis
66-
const requestCount = Number((await redis.get(key).catch(() => "0")) || "0");
53+
const limitPerWindow = limitPerSecond * SLIDING_WINDOW_SECONDS;
6754

68-
// Get the limit for this window accounting for the sample rate.
69-
const limitPerWindow =
70-
limitPerSecond * sampleRate * RATE_LIMIT_WINDOW_SECONDS;
71-
72-
if (requestCount > limitPerWindow) {
55+
if (totalCount > limitPerWindow) {
7356
return {
7457
rateLimited: true,
75-
requestCount,
58+
requestCount: totalCount,
7659
rateLimit: limitPerWindow,
7760
status: 429,
78-
errorMessage: `You've exceeded your ${serviceScope} rate limit at ${limitPerSecond} reqs/sec. To get higher rate limits, contact us at https://thirdweb.com/contact-us.`,
61+
errorMessage: `You've exceeded your ${serviceScope} rate limit at ${limitPerSecond} reqs/sec. Please upgrade your plan to get higher rate limits.`,
7962
errorCode: "RATE_LIMIT_EXCEEDED",
8063
};
8164
}
8265

83-
// do not await this, it just needs to execute at all
84-
(async () =>
85-
// always incrementBy the amount specified for the key
86-
await redis.incrby(key, increment).then(async () => {
87-
// if the initial request count was 0, set the key to expire in the future
88-
if (requestCount === 0) {
89-
await redis.expire(key, RATE_LIMIT_WINDOW_SECONDS);
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);
9078
}
91-
}))().catch(() => {
92-
console.error("Error incrementing rate limit key", key);
93-
});
79+
} catch (error) {
80+
console.error("Error updating rate limit key:", error);
81+
}
82+
})();
9483

9584
return {
9685
rateLimited: false,
97-
requestCount: requestCount + increment,
86+
requestCount: totalCount + increment,
9887
rateLimit: limitPerWindow,
9988
};
10089
}
90+
91+
function getRequestCountAtSecondCacheKey(
92+
serviceScope: CoreServiceConfig["serviceScope"],
93+
teamId: string,
94+
second: number,
95+
) {
96+
return `rate-limit:${serviceScope}:${teamId}:${second}`;
97+
}

0 commit comments

Comments
 (0)