Skip to content

Commit a9a5cc5

Browse files
committed
Add rate limiting to generate description API
Use atomic increments for rate limit counters Use server reads for rate limit counts Shard rate limit counters to reduce contention Add TTL expiration to rate limit shards Add rate limiting to generate description API
1 parent a264f2f commit a9a5cc5

3 files changed

Lines changed: 103 additions & 2 deletions

File tree

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)