mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): guard against oversized payload on the aggregated retry
The generate route caught `FeaturesPayloadTooLargeError` from the raw-mode `extractFeatures` call and retried in aggregated mode. The retry ran the same `enforceSizeGuard` from `features.ts`, so a very large medication history plus multi-year context could blow past the 5 MB ceiling a second time → the uncaught throw bubbled to the apiHandler's generic 500 path. The retry now sits in its own try/catch. On a second oversize the route applies the existing `applyInsightsExcludeFilter` with the optional context blocks (anthropometrics, medications, sleep, steps, hrv, resting_hr) excluded and re-extracts. A third failure annotates `insights_payload_too_large` for ops visibility and returns 422 with a readable user-facing message rather than 500. Unit test mocks all three extractor calls to throw and asserts the 422 + annotate.
This commit is contained in:
1 parent
7200fc8015
commit
d7ad2c0be0
2 files changed
+117
-4
No files matched your search
@@ -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<typeof import("@/lib/insights/features")>(
|
||||
"@/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<string, unknown> })?.meta
|
||||
?.insights_payload_too_large === true,
|
||||
);
|
||||
expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -286,13 +286,65 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let features: Awaited<ReturnType<typeof extractFeatures>>;
|
||||
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<string> = [
|
||||
"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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user