From 53e73898548d9ac73e0712854058432323f5fe92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 00:12:42 +0200 Subject: [PATCH] fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enforceBudget()` catches the cost dimension — once a user has burned the day's token allowance the route returns 429 with `coach.budget.exceeded`. It does not catch the request-rate dimension: a tight loop or a stolen session can issue 60 requests in a second, all accepted up to the moment the budget arithmetic finally rejects, while pinning Prisma connections and provider-side SSE slots in the meantime. A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in front of the budget gate. 20 requests per minute is well outside any realistic interactive pace — a human doesn't type that fast and the iOS client paces from user gestures — while keeping the budget logic as the cost ceiling it was designed to be. The 429 returns through `apiError()` for envelope parity with the other rate-limited routes; an `insights.coach.rate-limited` wide-event lands so operators can see when the limit fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/insights/chat/route.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 76a7f59ce..43a5a6f95 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -29,10 +29,11 @@ import { NextResponse, type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -154,6 +155,22 @@ async function handleChatRequest(request: NextRequest): Promise { } const { conversationId, message, locale: bodyLocale, scope } = parsed.data; + // Per-user request-rate ceiling layered in front of the daily budget + // gate. The budget catches the cost dimension; this catches the + // request-rate dimension (a tight loop or a stolen session can burn + // the budget in seconds while pinning Prisma + provider slots before + // the budget arithmetic catches up). 20 / minute is well outside any + // realistic interactive use — a human can't type that fast, the iOS + // client paces from user gestures. + const rl = await checkRateLimit(`coach-chat:${userId}`, 20, 60 * 1000); + if (!rl.allowed) { + annotate({ + action: { name: "insights.coach.rate-limited" }, + meta: { userId, resetAt: rl.resetAt }, + }); + return apiError("Too many Coach requests, please wait a moment", 429); + } + await enforceBudget(userId); const locale = await resolveServerLocale({