From d7dcf487bf086297301cc319f8a03004adc0624c Mon Sep 17 00:00:00 2001 From: MsGem0523 <59624833+msgem0523@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:37:50 -0400 Subject: [PATCH] feat: Add reusable API rate limiting helpers --- src/lib/rate-limit.ts | 53 +++++++++++++++++++++++++++++++++++++++- src/pages/api/ai/chat.ts | 5 ++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 89807af42..7c471777e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -5,7 +5,7 @@ * swap the Map for Redis or similar. */ -import type { NextApiRequest } from "next"; +import type { NextApiRequest, NextApiHandler, NextApiResponse } from "next"; interface RateLimitEntry { count: number; @@ -70,3 +70,54 @@ export function checkRateLimit( entry.count++; return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }; } + +export const RATE_LIMITS = { + default: { maxRequests: 20, windowMs: 15 * 60 * 1000 }, + strict: { maxRequests: 10, windowMs: 15 * 60 * 1000 }, + ai: { maxRequests: 5, windowMs: 15 * 60 * 1000 }, + search: { maxRequests: 30, windowMs: 15 * 60 * 1000 }, + public: { maxRequests: 50, windowMs: 15 * 60 * 1000 }, +} as const; + +export type RateLimitType = keyof typeof RATE_LIMITS; + +export function applyRateLimit( + req: NextApiRequest, + res: NextApiResponse, + type: RateLimitType = "default" +): boolean { + const ip = getClientIp(req); + const { maxRequests, windowMs } = RATE_LIMITS[type]; + const limit = checkRateLimit(ip, maxRequests, windowMs); + + const retryAfter = Math.max(1, Math.ceil((limit.resetAt - Date.now()) / 1000)); + + res.setHeader("X-RateLimit-Limit", maxRequests); + res.setHeader("X-RateLimit-Remaining", limit.remaining); + res.setHeader("X-RateLimit-Reset", Math.floor(limit.resetAt / 1000)); + + if (!limit.allowed) { + res.setHeader("Retry-After", retryAfter); + res.status(429).json({ + error: "Too many requests. Please try again later.", + retryAfter, + }); + + return false; + } + + return true; +} + +export function withRateLimit( + handler: NextApiHandler, + type: RateLimitType = "default" +): NextApiHandler { + return async function rateLimitedHandler(req, res) { + if (!applyRateLimit(req, res, type)) { + return; + } + + return handler(req, res); + }; +} \ No newline at end of file diff --git a/src/pages/api/ai/chat.ts b/src/pages/api/ai/chat.ts index 4807a2873..69ad8b84c 100644 --- a/src/pages/api/ai/chat.ts +++ b/src/pages/api/ai/chat.ts @@ -2,6 +2,7 @@ import { streamText } from "ai"; import { NextApiResponse } from "next"; import { getAIModelWithFallback, JODIE_SYSTEM_PROMPT } from "@/lib/ai-provider"; import { AuthenticatedRequest, requireAuth } from "@/lib/rbac"; +import { applyRateLimit } from "@/lib/rate-limit"; export const runtime = "nodejs"; export const maxDuration = 30; // 30 seconds max for streaming responses @@ -47,6 +48,10 @@ export default requireAuth(async (req: AuthenticatedRequest, res: NextApiRespons return res.status(405).json({ error: "Method not allowed" }); } + if (!applyRateLimit(req, res, "ai")) { + return; + } + try { const { messages, lessonContext } = req.body as ChatRequestBody;