Skip to content

Commit 0d46cc9

Browse files
committed
Easier to create a rate limiter, use it in the ApiRateLimiter. Upgraded the Upstash package
1 parent 782d4f7 commit 0d46cc9

File tree

4 files changed

+128
-73
lines changed

4 files changed

+128
-73
lines changed

apps/webapp/app/services/apiRateLimit.server.ts

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,21 @@
11
import { Ratelimit } from "@upstash/ratelimit";
22
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
3-
import Redis, { RedisOptions } from "ioredis";
3+
import { RedisOptions } from "ioredis";
44
import { createHash } from "node:crypto";
55
import { env } from "~/env.server";
66
import { logger } from "./logger.server";
7-
8-
function createRedisRateLimitClient(
9-
redisOptions: RedisOptions
10-
): ConstructorParameters<typeof Ratelimit>[0]["redis"] {
11-
const redis = new Redis(redisOptions);
12-
13-
return {
14-
sadd: async <TData>(key: string, ...members: TData[]): Promise<number> => {
15-
return redis.sadd(key, members as (string | number | Buffer)[]);
16-
},
17-
eval: <TArgs extends unknown[], TData = unknown>(
18-
...args: [script: string, keys: string[], args: TArgs]
19-
): Promise<TData> => {
20-
const script = args[0];
21-
const keys = args[1];
22-
const argsArray = args[2];
23-
return redis.eval(
24-
script,
25-
keys.length,
26-
...keys,
27-
...(argsArray as (string | Buffer | number)[])
28-
) as Promise<TData>;
29-
},
30-
};
31-
}
7+
import { Duration, Limiter, RateLimiter, createRedisRateLimitClient } from "./rateLimiter.server";
328

339
type Options = {
10+
redis?: RedisOptions;
11+
keyPrefix: string;
12+
pathMatchers: (RegExp | string)[];
13+
pathWhiteList?: (RegExp | string)[];
14+
limiter: Limiter;
3415
log?: {
3516
requests?: boolean;
3617
rejections?: boolean;
3718
};
38-
redis: RedisOptions;
39-
keyPrefix: string;
40-
pathMatchers: (RegExp | string)[];
41-
pathWhiteList?: (RegExp | string)[];
42-
limiter: ConstructorParameters<typeof Ratelimit>[0]["limiter"];
4319
};
4420

4521
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
@@ -54,12 +30,20 @@ export function authorizationRateLimitMiddleware({
5430
requests: true,
5531
},
5632
}: Options) {
57-
const rateLimiter = new Ratelimit({
58-
redis: createRedisRateLimitClient(redis),
59-
limiter: limiter,
60-
ephemeralCache: new Map(),
61-
analytics: false,
62-
prefix: keyPrefix,
33+
// const rateLimiter = new Ratelimit({
34+
// redis: createRedisRateLimitClient(redis),
35+
// limiter: limiter,
36+
// ephemeralCache: new Map(),
37+
// analytics: false,
38+
// prefix: keyPrefix,
39+
// });
40+
41+
const rateLimiter = new RateLimiter({
42+
redis,
43+
keyPrefix,
44+
limiter,
45+
logSuccess: log.requests,
46+
logFailure: log.rejections,
6347
});
6448

6549
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
@@ -135,27 +119,9 @@ export function authorizationRateLimitMiddleware({
135119
res.set("x-ratelimit-reset", reset.toString());
136120

137121
if (success) {
138-
if (log.requests) {
139-
logger.info(`RateLimiter (${keyPrefix}): under rate limit`, {
140-
limit,
141-
reset,
142-
remaining,
143-
hashedAuthorizationValue,
144-
});
145-
}
146122
return next();
147123
}
148124

149-
if (log.rejections) {
150-
logger.warn(`RateLimiter (${keyPrefix}): rate limit exceeded`, {
151-
limit,
152-
reset,
153-
remaining,
154-
pending,
155-
hashedAuthorizationValue,
156-
});
157-
}
158-
159125
res.setHeader("Content-Type", "application/problem+json");
160126
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
161127
return res.status(429).send(
@@ -167,6 +133,7 @@ export function authorizationRateLimitMiddleware({
167133
detail: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
168134
reset,
169135
limit,
136+
remaining,
170137
secondsUntilReset,
171138
error: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
172139
},
@@ -177,18 +144,8 @@ export function authorizationRateLimitMiddleware({
177144
};
178145
}
179146

180-
type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
181-
182147
export const apiRateLimiter = authorizationRateLimitMiddleware({
183148
keyPrefix: "ratelimit:api",
184-
redis: {
185-
port: env.REDIS_PORT,
186-
host: env.REDIS_HOST,
187-
username: env.REDIS_USERNAME,
188-
password: env.REDIS_PASSWORD,
189-
enableAutoPipelining: true,
190-
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
191-
},
192149
limiter: Ratelimit.slidingWindow(env.API_RATE_LIMIT_MAX, env.API_RATE_LIMIT_WINDOW as Duration),
193150
pathMatchers: [/^\/api/],
194151
// Allow /api/v1/tasks/:id/callback/:secret
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Ratelimit } from "@upstash/ratelimit";
2+
import Redis, { RedisOptions } from "ioredis";
3+
import { env } from "~/env.server";
4+
import { logger } from "./logger.server";
5+
6+
type Options = {
7+
redis?: RedisOptions;
8+
keyPrefix: string;
9+
limiter: Limiter;
10+
logSuccess?: boolean;
11+
logFailure?: boolean;
12+
};
13+
14+
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
15+
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
16+
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
17+
18+
export class RateLimiter {
19+
#ratelimit: Ratelimit;
20+
21+
constructor(private readonly options: Options) {
22+
const { redis, keyPrefix, limiter } = options;
23+
this.#ratelimit = new Ratelimit({
24+
redis: createRedisRateLimitClient(
25+
redis ?? {
26+
port: env.REDIS_PORT,
27+
host: env.REDIS_HOST,
28+
username: env.REDIS_USERNAME,
29+
password: env.REDIS_PASSWORD,
30+
enableAutoPipelining: true,
31+
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
32+
}
33+
),
34+
limiter,
35+
ephemeralCache: new Map(),
36+
analytics: false,
37+
prefix: keyPrefix,
38+
});
39+
}
40+
41+
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
42+
const result = this.#ratelimit.limit(identifier, { rate });
43+
const { success, limit, reset, remaining } = await result;
44+
45+
if (success && this.options.logSuccess) {
46+
logger.info(`RateLimiter (${this.options.keyPrefix}): under rate limit`, {
47+
limit,
48+
reset,
49+
remaining,
50+
identifier,
51+
});
52+
}
53+
54+
if (!success && this.options.logFailure) {
55+
logger.info(`RateLimiter (${this.options.keyPrefix}): rate limit exceeded`, {
56+
limit,
57+
reset,
58+
remaining,
59+
identifier,
60+
});
61+
}
62+
63+
return result;
64+
}
65+
}
66+
67+
export function createRedisRateLimitClient(
68+
redisOptions: RedisOptions
69+
): ConstructorParameters<typeof Ratelimit>[0]["redis"] {
70+
const redis = new Redis(redisOptions);
71+
72+
return {
73+
sadd: async <TData>(key: string, ...members: TData[]): Promise<number> => {
74+
return redis.sadd(key, members as (string | number | Buffer)[]);
75+
},
76+
hset: <TValue>(
77+
key: string,
78+
obj: {
79+
[key: string]: TValue;
80+
}
81+
): Promise<number> => {
82+
return redis.hset(key, obj);
83+
},
84+
eval: <TArgs extends unknown[], TData = unknown>(
85+
...args: [script: string, keys: string[], args: TArgs]
86+
): Promise<TData> => {
87+
const script = args[0];
88+
const keys = args[1];
89+
const argsArray = args[2];
90+
return redis.eval(
91+
script,
92+
keys.length,
93+
...keys,
94+
...(argsArray as (string | Buffer | number)[])
95+
) as Promise<TData>;
96+
},
97+
};
98+
}

apps/webapp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"@trigger.dev/yalt": "workspace:*",
102102
"@types/pg": "8.6.6",
103103
"@uiw/react-codemirror": "^4.19.5",
104-
"@upstash/ratelimit": "^1.0.1",
104+
"@upstash/ratelimit": "^1.1.3",
105105
"@whatwg-node/fetch": "^0.9.14",
106106
"assert-never": "^1.2.1",
107107
"aws4fetch": "^1.0.18",

pnpm-lock.yaml

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)