Skip to content

Commit 2cd4478

Browse files
authored
Merge pull request #72 from bigcommerce/codex/implement-rate-limits-for-generatedescription
Add rate limiting to generate description API
2 parents 9e27232 + a9a5cc5 commit 2cd4478

File tree

3 files changed

+103
-2
lines changed

3 files changed

+103
-2
lines changed

src/app/api/generateDescription/route.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { type NextRequest, NextResponse } from 'next/server';
22
import generateDescription from '~/server/google-ai';
33
import { aiSchema } from './schema';
44
import { authorize } from '~/lib/authorize';
5+
import { enforceRateLimit } from '~/lib/rate-limit';
6+
7+
const RATE_LIMIT_KEY = 'generateDescription';
8+
const RATE_LIMIT_MAX_REQUESTS = 60;
9+
const RATE_LIMIT_WINDOW_MS = 60_000;
510

611
export async function POST(req: NextRequest) {
712
const authToken = req.headers.get('X-Auth-Token') || 'missing';
@@ -10,6 +15,27 @@ export async function POST(req: NextRequest) {
1015
return new NextResponse('Unauthorized', { status: 401 });
1116
}
1217

18+
const clientIdentifier =
19+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.ip || 'unknown';
20+
const rateLimit = await enforceRateLimit(
21+
`${RATE_LIMIT_KEY}:${clientIdentifier}`,
22+
{ windowMs: RATE_LIMIT_WINDOW_MS, maxRequests: RATE_LIMIT_MAX_REQUESTS }
23+
);
24+
25+
if (!rateLimit.allowed) {
26+
const retryAfter = Math.max(0, Math.ceil((rateLimit.reset - Date.now()) / 1000));
27+
28+
return new NextResponse('Too Many Requests', {
29+
status: 429,
30+
headers: {
31+
'Retry-After': retryAfter.toString(),
32+
'X-RateLimit-Limit': rateLimit.limit.toString(),
33+
'X-RateLimit-Remaining': rateLimit.remaining.toString(),
34+
'X-RateLimit-Reset': rateLimit.reset.toString(),
35+
},
36+
});
37+
}
38+
1339
const data: unknown = await req.json();
1440
const parsedParams = aiSchema.safeParse(data);
1541

@@ -19,5 +45,11 @@ export async function POST(req: NextRequest) {
1945

2046
const description = await generateDescription(parsedParams.data);
2147

22-
return NextResponse.json({ description });
48+
const response = NextResponse.json({ description });
49+
50+
response.headers.set('X-RateLimit-Limit', rateLimit.limit.toString());
51+
response.headers.set('X-RateLimit-Remaining', rateLimit.remaining.toString());
52+
response.headers.set('X-RateLimit-Reset', rateLimit.reset.toString());
53+
54+
return response;
2355
}

src/lib/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface ClientTokenData {
3535
let app: FirebaseApp;
3636
let db: Firestore;
3737

38-
function getDb() {
38+
export function getDb() {
3939
if (!db) {
4040
const { FIRE_API_KEY, FIRE_DOMAIN, FIRE_PROJECT_ID } = env;
4141

src/lib/rate-limit.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
collection,
3+
doc,
4+
getDocsFromServer,
5+
increment,
6+
setDoc,
7+
Timestamp,
8+
} from 'firebase/firestore';
9+
import { getDb } from './db';
10+
11+
interface RateLimitOptions {
12+
windowMs?: number;
13+
maxRequests?: number;
14+
}
15+
16+
interface RateLimitDocument {
17+
count: number;
18+
windowStart: number;
19+
expiresAt?: Timestamp;
20+
}
21+
22+
export interface RateLimitState {
23+
allowed: boolean;
24+
remaining: number;
25+
reset: number;
26+
limit: number;
27+
}
28+
29+
const DEFAULT_WINDOW_MS = 60_000;
30+
const DEFAULT_MAX_REQUESTS = 30;
31+
const SHARD_COUNT = 20;
32+
// Documents are cleaned up via Firestore TTL configured on the `expiresAt` field.
33+
// Keeping a modest retention allows slow TTL sweeps without impacting active windows.
34+
const RETENTION_WINDOWS = 24; // number of windows to keep for TTL cleanup
35+
36+
export async function enforceRateLimit(
37+
key: string,
38+
{ windowMs = DEFAULT_WINDOW_MS, maxRequests = DEFAULT_MAX_REQUESTS }: RateLimitOptions = {}
39+
): Promise<RateLimitState> {
40+
const db = getDb();
41+
const now = Date.now();
42+
const windowStart = Math.floor(now / windowMs) * windowMs;
43+
const windowRef = collection(db, 'rateLimits', `${key}:${windowStart}`, 'shards');
44+
const shardId = Math.floor(Math.random() * SHARD_COUNT).toString();
45+
const shardRef = doc(windowRef, shardId);
46+
const expiresAt = Timestamp.fromMillis(windowStart + windowMs * RETENTION_WINDOWS);
47+
48+
await setDoc(
49+
shardRef,
50+
{ count: increment(1), windowStart, expiresAt },
51+
{ merge: true },
52+
);
53+
54+
const shardsSnapshot = await getDocsFromServer(windowRef);
55+
const count = shardsSnapshot.docs.reduce((sum, snapshot) => {
56+
const data = snapshot.data() as RateLimitDocument | undefined;
57+
58+
return sum + (data?.count ?? 0);
59+
}, 0);
60+
const remaining = Math.max(0, maxRequests - count);
61+
const reset = windowStart + windowMs;
62+
63+
return {
64+
allowed: count <= maxRequests,
65+
remaining,
66+
reset,
67+
limit: maxRequests,
68+
};
69+
}

0 commit comments

Comments
 (0)