mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint
`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) <noreply@anthropic.com>
This commit is contained in:
1 parent
7dc9dbaa44
commit
53e7389854
1 file changed
+18
-1
@@ -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<Response> {
|
||||
}
|
||||
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({
|
||||
|
||||
Reference in new issue
Block a user