Skip to content

Commit 82da8e2

Browse files
committed
feat: rate-limit
1 parent 7a34786 commit 82da8e2

File tree

7 files changed

+456
-100
lines changed

7 files changed

+456
-100
lines changed

apps/api/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
"@logtail/edge": "^0.5.5",
2020
"@openrouter/ai-sdk-provider": "^1.1.2",
2121
"@trpc/server": "^11.5.0",
22-
"ai": "^5.0.23",
22+
"@upstash/ratelimit": "^2.0.6",
23+
"ai": "^5.0.27",
2324
"autumn-js": "^0.1.20",
2425
"dayjs": "^1.11.13",
2526
"elysia": "^1.3.20",
26-
"jszip": "^3.10.1"
27+
"jszip": "^3.10.1",
28+
"pino-pretty": "^13.1.1"
2729
}
2830
}

apps/api/src/lib/logger.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
1-
import { Logtail } from '@logtail/edge';
1+
// import { Logtail } from '@logtail/edge';
22

3-
const token = process.env.LOGTAIL_SOURCE_TOKEN as string;
4-
const endpoint = process.env.LOGTAIL_ENDPOINT as string;
3+
// const token = process.env.LOGTAIL_SOURCE_TOKEN as string;
4+
// const endpoint = process.env.LOGTAIL_ENDPOINT as string;
55

6-
if (!(token && endpoint)) {
7-
console.log('LOGTAIL_SOURCE_TOKEN and LOGTAIL_ENDPOINT must be set');
8-
}
6+
// if (!(token && endpoint)) {
7+
// console.log('LOGTAIL_SOURCE_TOKEN and LOGTAIL_ENDPOINT must be set');
8+
// }
99

10-
export const logger = new Logtail(token, {
11-
endpoint,
12-
batchSize: 10,
13-
batchInterval: 1000,
10+
// export const logger = new Logtail(token, {
11+
// endpoint,
12+
// batchSize: 10,
13+
// batchInterval: 1000,
14+
// });
15+
16+
import { pino } from 'pino';
17+
18+
const logger = pino({
19+
level: 'debug',
20+
transport: {
21+
target: 'pino-pretty',
22+
options: {
23+
colorize: true,
24+
},
25+
},
1426
});
27+
28+
export { logger };

apps/api/src/lib/rate-limit.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { Ratelimit } from '@upstash/ratelimit';
2+
import { Redis } from '@upstash/redis';
3+
4+
export const RATE_LIMIT_CONFIGS = {
5+
public: { requests: 100, window: '1 m' },
6+
api: { requests: 200, window: '1 m' },
7+
auth: { requests: 30, window: '1 m' },
8+
expensive: { requests: 30, window: '1 m' },
9+
admin: { requests: 500, window: '1 m' },
10+
} as const;
11+
12+
export type RateLimitType = keyof typeof RATE_LIMIT_CONFIGS;
13+
14+
export interface RateLimitOptions {
15+
type: RateLimitType;
16+
identifier?: string;
17+
skipAuth?: boolean;
18+
customConfig?: { requests: number; window: string };
19+
rate?: number;
20+
}
21+
22+
export interface RateLimitResult {
23+
success: boolean;
24+
limit: number;
25+
remaining: number;
26+
reset: Date;
27+
}
28+
29+
let redisClient: Redis;
30+
31+
const rateLimiterCache = new Map<string, Ratelimit>();
32+
const ephemeralCache = new Map<string, number>();
33+
const inMemoryLimits = new Map<string, { count: number; resetTime: number }>();
34+
35+
// Plan-based rate limiters matching pricing tiers
36+
const redis = Redis.fromEnv();
37+
38+
export const ratelimit = {
39+
free: new Ratelimit({
40+
redis,
41+
analytics: true,
42+
prefix: 'ratelimit:free',
43+
limiter: Ratelimit.slidingWindow(50, '10s'),
44+
}),
45+
hobby: new Ratelimit({
46+
redis,
47+
analytics: true,
48+
prefix: 'ratelimit:hobby',
49+
limiter: Ratelimit.slidingWindow(100, '10s'),
50+
}),
51+
pro: new Ratelimit({
52+
redis,
53+
analytics: true,
54+
prefix: 'ratelimit:pro',
55+
limiter: Ratelimit.slidingWindow(200, '10s'),
56+
}),
57+
scale: new Ratelimit({
58+
redis,
59+
analytics: true,
60+
prefix: 'ratelimit:scale',
61+
limiter: Ratelimit.slidingWindow(500, '10s'),
62+
}),
63+
};
64+
65+
function getRedisClient(): Redis | null {
66+
if (
67+
!redisClient &&
68+
process.env.UPSTASH_REDIS_REST_URL &&
69+
process.env.UPSTASH_REDIS_REST_TOKEN
70+
) {
71+
try {
72+
redisClient = redis; // Use the same Redis instance
73+
} catch {
74+
// ignore
75+
}
76+
}
77+
return redisClient || null;
78+
}
79+
80+
function createRateLimiter(
81+
type: RateLimitType,
82+
customConfig?: { requests: number; window: string }
83+
): Ratelimit | null {
84+
const config = customConfig || RATE_LIMIT_CONFIGS[type];
85+
const cacheKey = `${type}-${config.requests}-${config.window}`;
86+
87+
const cached = rateLimiterCache.get(cacheKey);
88+
if (cached) {
89+
return cached;
90+
}
91+
92+
const redisInstance = getRedisClient();
93+
if (!redisInstance) {
94+
return null;
95+
}
96+
97+
try {
98+
const rateLimiter = new Ratelimit({
99+
redis: redisInstance,
100+
limiter: Ratelimit.slidingWindow(config.requests, config.window as '1 m'),
101+
analytics: true,
102+
prefix: `@databuddy/ratelimit:${type}`,
103+
ephemeralCache,
104+
});
105+
rateLimiterCache.set(cacheKey, rateLimiter);
106+
return rateLimiter;
107+
} catch {
108+
return null;
109+
}
110+
}
111+
112+
function getRateLimitIdentifier(
113+
request: Request,
114+
customIdentifier?: string,
115+
userId?: string
116+
): string {
117+
if (customIdentifier) {
118+
return `custom:${customIdentifier}`;
119+
}
120+
if (userId) {
121+
return `user:${userId}`;
122+
}
123+
124+
const apiKey =
125+
request.headers.get('x-api-key') ||
126+
request.headers.get('authorization')?.replace('Bearer ', '');
127+
if (apiKey) {
128+
return `apikey:${apiKey.substring(0, 8)}`;
129+
}
130+
131+
const ip =
132+
request.headers.get('x-forwarded-for') ||
133+
request.headers.get('x-real-ip') ||
134+
'unknown';
135+
return `ip:${ip}`;
136+
}
137+
138+
function checkInMemoryRateLimit(
139+
identifier: string,
140+
config: { requests: number; window: string },
141+
rate = 1
142+
): RateLimitResult {
143+
const windowMs = config.window === '1 m' ? 60_000 : 60_000;
144+
const now = Date.now();
145+
const resetTime = Math.ceil(now / windowMs) * windowMs;
146+
const key = `${identifier}:${resetTime}`;
147+
148+
// Cleanup old entries
149+
for (const [k, v] of inMemoryLimits.entries()) {
150+
if (v.resetTime < now) {
151+
inMemoryLimits.delete(k);
152+
}
153+
}
154+
155+
const current = inMemoryLimits.get(key) || { count: 0, resetTime };
156+
const newCount = current.count + rate;
157+
const success = newCount <= config.requests;
158+
159+
if (success) {
160+
inMemoryLimits.set(key, { count: newCount, resetTime });
161+
}
162+
163+
return {
164+
success,
165+
limit: config.requests,
166+
remaining: Math.max(0, config.requests - newCount),
167+
reset: new Date(resetTime + windowMs),
168+
};
169+
}
170+
171+
export async function checkRateLimit(
172+
request: Request,
173+
options: RateLimitOptions,
174+
userId?: string
175+
): Promise<RateLimitResult> {
176+
const identifier = getRateLimitIdentifier(
177+
request,
178+
options.identifier,
179+
userId
180+
);
181+
const config = options.customConfig || RATE_LIMIT_CONFIGS[options.type];
182+
const rate = options.rate || 1;
183+
184+
const rateLimiter = createRateLimiter(options.type, options.customConfig);
185+
if (rateLimiter) {
186+
try {
187+
const result = await rateLimiter.limit(
188+
identifier,
189+
rate > 1 ? { rate } : undefined
190+
);
191+
return {
192+
success: result.success,
193+
limit: result.limit,
194+
remaining: result.remaining,
195+
reset: new Date(result.reset),
196+
};
197+
} catch {
198+
// ignore
199+
}
200+
}
201+
202+
const result = checkInMemoryRateLimit(identifier, config, rate);
203+
return result;
204+
}

0 commit comments

Comments
 (0)