diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 5ebae2f21..439d3630d 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -80,9 +80,16 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); -vi.mock("@/lib/insights/features", () => ({ - extractFeatures: vi.fn(async () => ({ stub: true })), -})); +vi.mock("@/lib/insights/features", async () => { + const actual = + await vi.importActual( + "@/lib/insights/features", + ); + return { + ...actual, + extractFeatures: vi.fn(async () => ({ stub: true })), + }; +}); vi.mock("@/lib/insights/prompt", () => ({ buildUserPrompt: vi.fn(() => "user"), @@ -97,6 +104,11 @@ import { resolveProvider } from "@/lib/ai/provider"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { clearLastWorkingProviderCache } from "@/lib/ai/provider-runner"; +import { + extractFeatures, + FeaturesPayloadTooLargeError, +} from "@/lib/insights/features"; +import { annotate } from "@/lib/logging/context"; beforeEach(() => { vi.clearAllMocks(); @@ -344,3 +356,51 @@ describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled(); }); }); + +// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated +// retry cross the 5 MB ceiling, the route used to throw uncaught → 500. +// It now wraps the retry, downgrades further via the exclude filter, +// and on a third failure returns 422 with an annotate event for ops. +describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () => { + it("returns 422 with an annotate event when every fallback shape is oversized", async () => { + makeWorkingProvider(); + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + // Raw mode so the route's first extractFeatures call is the + // wide shape; the retry asks for the aggregated shape. + insightsPrivacyMode: "raw", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + locale: "en", + } as never); + + // Each call mimics features.ts blowing past the ceiling. Three + // shots: raw, aggregated retry, hard-downgrade aggregated. + vi.mocked(extractFeatures).mockReset(); + vi.mocked(extractFeatures) + .mockRejectedValueOnce( + new FeaturesPayloadTooLargeError(6_000_000, 5_242_880), + ) + .mockRejectedValueOnce( + new FeaturesPayloadTooLargeError(5_500_000, 5_242_880), + ) + .mockRejectedValueOnce( + new FeaturesPayloadTooLargeError(5_300_000, 5_242_880), + ); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(422); + const body = (await res.json()) as ApiErrorEnvelope; + expect(body.error).toMatch(/payload size/i); + + // The annotate event fires with insights_payload_too_large so ops + // can spot the regression in the wide-event pipeline. + const calls = vi.mocked(annotate).mock.calls; + const annotated = calls.find( + (call) => + (call[0] as { meta?: Record })?.meta + ?.insights_payload_too_large === true, + ); + expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy(); + }); +}); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 61dcc1e52..96c7e3413 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -286,13 +286,65 @@ export const POST = apiHandler(async (request: NextRequest) => { let features: Awaited>; let payloadDowngraded = false; let payloadOversizeBytes: number | null = null; + // v1.4.36 QA H1 — extra fallback when even the aggregated shape + // crosses the 5 MB ceiling (very large medication history + multi- + // year context). Strips anthropometrics + medications + every other + // exclude-token-mapped block and tries one more time. On a third + // failure we return 422 with an annotate event for ops visibility + // rather than 500-ing out of an uncaught throw. + const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ + "anthropometrics", + "medications", + "compliance", + "sleep", + "steps", + "hrv", + "resting_hr", + ]; + let payloadHardDowngraded = false; try { features = await extractFeatures(userId, includeRaw); } catch (err) { if (err instanceof FeaturesPayloadTooLargeError) { payloadDowngraded = true; payloadOversizeBytes = err.sizeBytes; - features = await extractFeatures(userId, false); + try { + features = await extractFeatures(userId, false); + } catch (retryErr) { + if (retryErr instanceof FeaturesPayloadTooLargeError) { + // Aggregated shape ALSO crossed the ceiling. Drop optional + // context blocks (anthropometrics, medications, sleep, + // steps, hrv, resting_hr) via the existing exclude filter + // and try once more. + payloadHardDowngraded = true; + payloadOversizeBytes = retryErr.sizeBytes; + try { + const aggregated = await extractFeatures(userId, false); + features = applyInsightsExcludeFilter( + aggregated, + MAX_DOWNGRADE_TOKENS, + ); + } catch (finalErr) { + // Even the aggregated read itself blew up. Annotate and + // return 422 rather than let the uncaught throw 500 out. + annotate({ + meta: { + insights_payload_too_large: true, + insights_payload_oversize_bytes: + finalErr instanceof FeaturesPayloadTooLargeError + ? finalErr.sizeBytes + : payloadOversizeBytes, + }, + }); + return apiError( + "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", + 422, + ); + } + } else { + throw retryErr; + } + } } else { throw err; } @@ -315,6 +367,7 @@ export const POST = apiHandler(async (request: NextRequest) => { meta: { insights_features_downgraded: true, insights_features_oversize_bytes: payloadOversizeBytes, + insights_features_hard_downgraded: payloadHardDowngraded, }, }); }