Skip to content

Commit c214bf6

Browse files
ouiliameclaude
andcommitted
docs: add per-IP rate limiting to the Ask AI route
In-memory fixed-window limit (20 req/min/IP) caps single-source volume on the hot path, returning 429 with Retry-After. Best-effort on serverless; a shared store + edge WAF remain the durable controls, but this closes the no-volume-cap gap flagged in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d807260 commit c214bf6

1 file changed

Lines changed: 53 additions & 2 deletions

File tree

apps/docs/app/api/chat/route.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ function matchesLocale(sourceDocument: string, locale: string): boolean {
3535
/**
3636
* Abuse guards. This endpoint proxies a paid LLM, so an unauthenticated public
3737
* route is a target for scripted "free inference". These bounds cap the cost of
38-
* any single request; durable per-IP rate limiting, a provider spend cap, and
39-
* edge bot protection are provisioned separately (see the PR checklist).
38+
* any single request; an in-memory per-IP rate limit (below) caps volume on the
39+
* hot path. A shared-store rate limit, a provider spend cap, and edge bot
40+
* protection remain the durable controls (see the PR checklist).
4041
*
4142
* The size cap counts only user-authored text — NOT the conversation history,
4243
* assistant turns, or retrieved doc chunks we add via the searchDocs tool, which
@@ -49,6 +50,46 @@ const MAX_STEPS = 6
4950
/** Backstop on the whole serialized payload — blocks stuffing assistant/tool parts past the user-text cap. */
5051
const MAX_TOTAL_CHARS = 1_000_000
5152

53+
/**
54+
* Per-IP rate limit. Fixed window, in-memory: this bounds volume from a single
55+
* source on a warm instance without external infra. It is best-effort on
56+
* serverless (state is per-instance, not shared across regions/cold starts);
57+
* a shared store (e.g. Vercel KV) and an edge WAF remain the durable controls,
58+
* but this closes the "no volume limit at all" gap on the hot path.
59+
*/
60+
const RATE_LIMIT_MAX = 20
61+
const RATE_LIMIT_WINDOW_MS = 60_000
62+
const rateLimitHits = new Map<string, { count: number; resetAt: number }>()
63+
64+
/** Resolve the client IP from forwarding headers, falling back to a shared bucket. */
65+
function getClientIp(req: Request): string {
66+
const forwarded = req.headers.get('x-forwarded-for')
67+
if (forwarded) return forwarded.split(',')[0].trim()
68+
return req.headers.get('x-real-ip') ?? 'unknown'
69+
}
70+
71+
/** Fixed-window check. Returns retry-after seconds when the caller is over the limit, else null. */
72+
function rateLimit(ip: string, now: number): number | null {
73+
const entry = rateLimitHits.get(ip)
74+
if (!entry || now >= entry.resetAt) {
75+
rateLimitHits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
76+
return null
77+
}
78+
if (entry.count >= RATE_LIMIT_MAX) {
79+
return Math.ceil((entry.resetAt - now) / 1000)
80+
}
81+
entry.count += 1
82+
return null
83+
}
84+
85+
/** Drop expired buckets so the Map doesn't grow unbounded on a long-lived instance. */
86+
function sweepRateLimit(now: number): void {
87+
if (rateLimitHits.size < 10_000) return
88+
for (const [ip, entry] of rateLimitHits) {
89+
if (now >= entry.resetAt) rateLimitHits.delete(ip)
90+
}
91+
}
92+
5293
/** A structurally valid UI message: has a role and a parts array. */
5394
function isValidMessage(message: unknown): message is UIMessage {
5495
return (
@@ -144,6 +185,16 @@ export async function POST(req: Request) {
144185
return new Response('Forbidden', { status: 403 })
145186
}
146187

188+
const now = Date.now()
189+
sweepRateLimit(now)
190+
const retryAfter = rateLimit(getClientIp(req), now)
191+
if (retryAfter !== null) {
192+
return new Response('Too many requests', {
193+
status: 429,
194+
headers: { 'Retry-After': String(retryAfter) },
195+
})
196+
}
197+
147198
let body: { messages: UIMessage[]; locale?: string }
148199
try {
149200
body = await req.json()

0 commit comments

Comments
 (0)