Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/open-ends-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@thirdweb-dev/service-utils": patch
---

Update rate limit to sliding window
103 changes: 50 additions & 53 deletions packages/service-utils/src/core/rateLimit/index.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,34 @@
import type { CoreServiceConfig, TeamResponse } from "../api.js";
import type { RateLimitResult } from "./types.js";

const RATE_LIMIT_WINDOW_SECONDS = 10;
const SLIDING_WINDOW_SECONDS = 10;

// Redis interface compatible with ioredis (Node) and upstash (Cloudflare Workers).
type IRedis = {
get: (key: string) => Promise<string | null>;
expire(key: string, seconds: number): Promise<number>;
incrby(key: string, value: number): Promise<number>;
mget(keys: string[]): Promise<(string | null)[]>;
expire(key: string, seconds: number): Promise<number>;
};

/**
* Increments the request count for this team and returns whether the team has hit their rate limit.
* Uses a sliding 10 second window.
* @param args
* @returns
*/
export async function rateLimit(args: {
team: TeamResponse;
limitPerSecond: number;
serviceConfig: CoreServiceConfig;
redis: IRedis;
/**
* Sample requests to reduce load on Redis.
* This scales down the request count and the rate limit threshold.
* @default 1.0
*/
sampleRate?: number;
/**
* The number of requests to increment by.
* @default 1
*/
increment?: number;
}): Promise<RateLimitResult> {
const {
team,
limitPerSecond,
serviceConfig,
redis,
sampleRate = 1.0,
increment = 1,
} = args;

const shouldSampleRequest = Math.random() < sampleRate;
if (!shouldSampleRequest) {
return {
rateLimited: false,
requestCount: 0,
rateLimit: 0,
};
}
const { team, limitPerSecond, serviceConfig, redis, increment = 1 } = args;
const { serviceScope } = serviceConfig;

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

const serviceScope = serviceConfig.serviceScope;

// Gets the 10-second window for the current timestamp.
const timestampWindow =
Math.floor(Date.now() / (1000 * RATE_LIMIT_WINDOW_SECONDS)) *
RATE_LIMIT_WINDOW_SECONDS;
const key = `rate-limit:${serviceScope}:${team.id}:${timestampWindow}`;
// Enforce rate limit: sum the total requests in the last `SLIDING_WINDOW_SECONDS` seconds.
const currentSecond = Math.floor(Date.now() / 1000);
const keys = Array.from({ length: SLIDING_WINDOW_SECONDS }, (_, i) =>
getRequestCountAtSecondCacheKey(serviceScope, team.id, currentSecond - i),
);
const counts = await redis.mget(keys);
const totalCount = counts.reduce(
(sum, count) => sum + (count ? Number.parseInt(count) : 0),
0,
);

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

// Get the limit for this window accounting for the sample rate.
const limitPerWindow =
limitPerSecond * sampleRate * RATE_LIMIT_WINDOW_SECONDS;

if (requestCount > limitPerWindow) {
if (totalCount > limitPerWindow) {
return {
rateLimited: true,
requestCount,
requestCount: totalCount,
rateLimit: limitPerWindow,
status: 429,
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.`,
errorMessage: `You've exceeded your ${serviceScope} rate limit at ${limitPerSecond} reqs/sec. Please upgrade your plan to get higher rate limits.`,
errorCode: "RATE_LIMIT_EXCEEDED",
};
}

// do not await this, it just needs to execute at all
(async () =>
// always incrementBy the amount specified for the key
await redis.incrby(key, increment).then(async () => {
// if the initial request count was 0, set the key to expire in the future
if (requestCount === 0) {
await redis.expire(key, RATE_LIMIT_WINDOW_SECONDS);
// Non-blocking: increment the request count for the current second.
(async () => {
try {
const key = getRequestCountAtSecondCacheKey(
serviceScope,
team.id,
currentSecond,
);
await redis.incrby(key, increment);
// If this is the first time setting this key, expire it after the sliding window is past.
if (counts[0] === null) {
await redis.expire(key, SLIDING_WINDOW_SECONDS + 1);
}
}))().catch(() => {
console.error("Error incrementing rate limit key", key);
});
} catch (error) {
console.error("Error updating rate limit key:", error);
}
})();

return {
rateLimited: false,
requestCount: requestCount + increment,
requestCount: totalCount + increment,
rateLimit: limitPerWindow,
};
}

function getRequestCountAtSecondCacheKey(
serviceScope: CoreServiceConfig["serviceScope"],
teamId: string,
second: number,
) {
return `rate-limit:${serviceScope}:${teamId}:${second}`;
}
Loading
Loading