mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): cap status-card provider calls at 20s with graceful fallback
The seven `*-status.ts` routes awaited `provider.generateCompletion`
with no client timeout. When the upstream stalled, React-Query's
three default retries plus exponential backoff stacked on top of
the upstream latency and the InsightStatusCard spun for up to 90 s
before the user saw the cached fallback or a generic failure. This
is the downstream half of FB-D2 ("pulse insights loads forever").
Add a small `withTimeout` helper that races the provider call
against a 20 s budget and resolves with a typed `{ timedOut, value }`
envelope. Every status route returns the no-key fallback text in a
cached-style response shape on timeout; the audit-log cache is NOT
written so a transient upstream stall cannot poison tomorrow's
hit. `retry: 0` on `useInsightStatus` stops the client from
re-firing the same expensive call after the deterministic fallback
lands.
Cap the per-route Prisma reads at the same time — `take: 365` for
PULSE / WEIGHT, `take: 1095` for the BP three-channel surfaces,
`take: 90` for MoodEntry, `take: 5000` for the general snapshot.
The downstream `applyPayloadBudget` trims further; without the cap
power users pulled tens of thousands of rows on every refresh.
iOS contract — `/api/insights/*-status` routes are web-only (iOS
runs its own on-device summaries against `/api/measurements/series`).
No public-API shape changes; pure server-internal hardening.
This commit is contained in:
1 parent
b00be2863d
commit
0d591ac928
10 files changed
+540
-174
No files matched your search
@@ -78,5 +78,10 @@ export function useInsightStatus(metric: InsightStatusMetric) {
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
// v1.4.28 FB-D2 — the status route now returns a deterministic
|
||||
// cached-style envelope on provider timeout. Retrying that response
|
||||
// re-fires the same expensive upstream call and the user perceives
|
||||
// a longer hang. Drop retries to surface the fallback immediately.
|
||||
retry: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3a FB-D2 — provider timeout fallback for the pulse-status
|
||||
* route. When the upstream `generateCompletion` call does not resolve
|
||||
* inside the 20-second budget the route returns a deterministic
|
||||
* cached-style envelope so the InsightStatusCard renders the
|
||||
* fallback text instead of spinning behind React-Query's default
|
||||
* retries.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
|
||||
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { generatePulseStatusForUser } from "../pulse-status";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — provider timeout fallback", () => {
|
||||
it("returns a cached-style envelope when the provider exceeds 20 s", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 72, measuredAt: new Date() },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
|
||||
// Provider stalls indefinitely.
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(
|
||||
() => new Promise<{ content: string }>(() => {}),
|
||||
),
|
||||
} as never);
|
||||
|
||||
const promise = generatePulseStatusForUser("user-1", { locale: "en" });
|
||||
// Advance past the 20-second budget so the timeout race wins.
|
||||
await vi.advanceTimersByTimeAsync(21_000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.hasProvider).toBe(true);
|
||||
expect(result.cached).toBe(true);
|
||||
expect(typeof result.text).toBe("string");
|
||||
expect(result.text?.length ?? 0).toBeGreaterThan(0);
|
||||
// The route must NOT persist a fallback into the audit cache —
|
||||
// a transient provider stall should not poison tomorrow's cache
|
||||
// hit.
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -179,20 +183,27 @@ export async function generateBloodPressureStatusForUser(
|
||||
}
|
||||
}
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
|
||||
// `applyPayloadBudget` trims further; this `take` keeps the read
|
||||
// bounded for Apple-Health-rich accounts. BP captures three types
|
||||
// per reading day so 1095 = 365 d × 3 channels.
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1095,
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -352,12 +363,17 @@ export async function generateBloodPressureStatusForUser(
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch mood context (optional — for enrichment only)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
// Fetch mood context (optional — for enrichment only). v1.4.28
|
||||
// FB-D2 — cap at 90 entries (~3 months) so power users don't pull
|
||||
// unbounded rows before the bucket-series budget runs.
|
||||
const moodEntries = await prisma.moodEntry
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 90,
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const moodSeries = applyPayloadBudget(
|
||||
moodEntries.map((entry) => ({
|
||||
@@ -496,18 +512,35 @@ export async function generateBloodPressureStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getBloodPressureSystemPrompt(locale),
|
||||
userPrompt: getBloodPressureUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
|
||||
// on stall so the InsightStatusCard renders deterministically.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getBloodPressureSystemPrompt(locale),
|
||||
userPrompt: getBloodPressureUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyBloodPressureStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error("AI returned empty content for blood-pressure-status");
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "@/lib/insights/memory";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -141,17 +145,23 @@ export async function generateBmiStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
// v1.4.28 FB-D2 — cap the snapshot input (weight runs at most once
|
||||
// per day for typical users; 365 covers a full year while the
|
||||
// downstream payload budget trims further).
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365,
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
const weightSeries = applyPayloadBudget(
|
||||
@@ -250,18 +260,35 @@ export async function generateBmiStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getBmiSystemPrompt(locale),
|
||||
userPrompt: getBmiUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
|
||||
// on stall so the InsightStatusCard renders deterministically.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getBmiSystemPrompt(locale),
|
||||
userPrompt: getBmiUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyBmiStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error("AI returned empty content for bmi-status");
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from "@/lib/insights/memory";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -137,20 +141,26 @@ export async function generateGeneralStatusForUser(
|
||||
}
|
||||
}
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: {
|
||||
in: [...MEASUREMENT_TYPES],
|
||||
// v1.4.28 FB-D2 — cap the snapshot input. General-status pulls
|
||||
// every supported measurement type; without a cap the read scales
|
||||
// linearly with account density.
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: {
|
||||
in: [...MEASUREMENT_TYPES],
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 5000,
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -219,12 +229,16 @@ export async function generateGeneralStatusForUser(
|
||||
}));
|
||||
const adherenceSeries = applyPayloadBudget(adherenceRecords, { now });
|
||||
|
||||
// Fetch mood context (optional — for enrichment only)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
// Fetch mood context (optional — for enrichment only). v1.4.28
|
||||
// FB-D2 — cap at 90 entries.
|
||||
const moodEntries = await prisma.moodEntry
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 90,
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const moodRecords = moodEntries.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
@@ -345,18 +359,35 @@ export async function generateGeneralStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getGeneralStatusSystemPrompt(locale),
|
||||
userPrompt: getGeneralStatusUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
|
||||
// on stall so the InsightStatusCard renders deterministically.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getGeneralStatusSystemPrompt(locale),
|
||||
userPrompt: getGeneralStatusUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyGeneralStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error("AI returned empty content for general-status");
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from "@/lib/insights/memory";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
|
||||
@@ -308,18 +312,36 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getMedicationComplianceSystemPrompt(locale),
|
||||
userPrompt: getMedicationComplianceUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
|
||||
// on stall so the InsightStatusCard renders deterministically.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getMedicationComplianceSystemPrompt(locale),
|
||||
userPrompt: getMedicationComplianceUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
summary: getNoKeyMedicationComplianceStatusText(locale),
|
||||
medications: [],
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -161,18 +165,25 @@ export async function generateMoodStatusForUser(
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
score: true,
|
||||
tags: true,
|
||||
moodLoggedAt: true,
|
||||
},
|
||||
});
|
||||
// v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood
|
||||
// surface. The downstream `applyPayloadBudget` slims further.
|
||||
// Order desc + reverse so the per-row consumers downstream (which
|
||||
// expect oldest-first) see the same shape.
|
||||
const entries = await prisma.moodEntry
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take: 90,
|
||||
select: {
|
||||
date: true,
|
||||
score: true,
|
||||
tags: true,
|
||||
moodLoggedAt: true,
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -226,15 +237,21 @@ export async function generateMoodStatusForUser(
|
||||
? Math.round((Date.now() - newestEntry.getTime()) / (24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
|
||||
// Fetch cross-metric context for enrichment
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
// Fetch cross-metric context for enrichment. v1.4.28 FB-D2 — cap at
|
||||
// 1095 = 365 d × 3 channels so power users do not pull unbounded
|
||||
// rows. Order desc + reverse so the downstream filters see oldest-
|
||||
// first as before.
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1095,
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const weightSeries = applyPayloadBudget(
|
||||
measurements
|
||||
@@ -400,18 +417,35 @@ export async function generateMoodStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getMoodSystemPrompt(locale),
|
||||
userPrompt: getMoodUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
|
||||
// on stall so the InsightStatusCard renders deterministically.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getMoodSystemPrompt(locale),
|
||||
userPrompt: getMoodUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyMoodStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error("AI returned empty content for mood-status");
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -136,17 +140,22 @@ export async function generatePulseStatusForUser(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
|
||||
// `applyPayloadBudget` trims further but the unbounded findMany was
|
||||
// pulling tens of thousands of rows for Apple-Health-rich accounts
|
||||
// before the budget call even started.
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "PULSE",
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365,
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
}).then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -161,12 +170,17 @@ export async function generatePulseStatusForUser(
|
||||
pulseSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
|
||||
// Fetch mood context (optional — for enrichment only)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
// Fetch mood context (optional — for enrichment only). Cap at 90
|
||||
// entries (~3 months); the bucket-series budget then summarises
|
||||
// further. v1.4.28 FB-D2 — prevent unbounded reads for power users.
|
||||
const moodEntries = await prisma.moodEntry
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 90,
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const moodSeries = applyPayloadBudget(
|
||||
moodEntries.map((entry) => ({
|
||||
@@ -286,18 +300,39 @@ export async function generatePulseStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getPulseSystemPrompt(locale),
|
||||
userPrompt: getPulseUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — cap the provider round-trip at 20 s. On timeout
|
||||
// (or upstream failure) return the no-key fallback text in a
|
||||
// cached-style envelope so the status card renders deterministically
|
||||
// instead of spinning behind React-Query's default retry ladder.
|
||||
// The fallback is NOT persisted to the audit cache — a transient
|
||||
// upstream stall would otherwise poison tomorrow's hit.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getPulseSystemPrompt(locale),
|
||||
userPrompt: getPulseUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyPulseStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error("AI returned empty content for pulse-status");
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
type DailyBucket,
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
withTimeout,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -164,20 +168,26 @@ export async function generateWeightStatusForUser(
|
||||
}
|
||||
}
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
// v1.4.28 FB-D2 — cap the snapshot input. BP captures three types
|
||||
// per day so 1095 = 365 d × 3 channels. Order desc + reverse so we
|
||||
// keep the most recent year of rows.
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
take: 1095,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -211,12 +221,16 @@ export async function generateWeightStatusForUser(
|
||||
{ now },
|
||||
);
|
||||
|
||||
// Fetch mood context (optional — for enrichment only)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
// Fetch mood context (optional — for enrichment only). v1.4.28
|
||||
// FB-D2 — cap at 90 entries.
|
||||
const moodEntries = await prisma.moodEntry
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 90,
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const moodSeries = applyPayloadBudget(
|
||||
moodEntries.map((entry) => ({
|
||||
@@ -396,18 +410,35 @@ export async function generateWeightStatusForUser(
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getWeightSystemPrompt(locale),
|
||||
userPrompt: getWeightUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
|
||||
// on stall so the InsightStatusCard renders deterministically.
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
provider.generateCompletion({
|
||||
systemPrompt: getWeightSystemPrompt(locale),
|
||||
userPrompt: getWeightUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
);
|
||||
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyWeightStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = raced.value;
|
||||
const content = result.content;
|
||||
if (typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new Error("AI returned empty content for weight-status");
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.4.28 R3a FB-D2 — race an awaited promise against a hard timeout.
|
||||
*
|
||||
* The seven `*-status.ts` routes await `provider.generateCompletion()`
|
||||
* with no client-side timeout. When the upstream provider stalls (cold
|
||||
* cache, transient brown-out) React-Query's three default retries plus
|
||||
* default exponential backoff stacks on top of the upstream latency
|
||||
* and the InsightStatusCard spins for up to 90 s before the user sees
|
||||
* either the cached fallback text or a generic failure.
|
||||
*
|
||||
* `withTimeout(promise, ms, fallback)` resolves with the wrapped
|
||||
* promise's value when it lands inside the budget, or the `fallback`
|
||||
* value otherwise. Use the `signal` payload to abort upstream work so
|
||||
* the original promise does not keep consuming a provider slot after
|
||||
* the caller has moved on.
|
||||
*
|
||||
* Returns a typed envelope so the call site can distinguish "timed
|
||||
* out" from "the upstream returned this value" without re-encoding
|
||||
* the fallback as a magic string.
|
||||
*/
|
||||
export interface TimeoutEnvelope<T> {
|
||||
timedOut: boolean;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export function withTimeout<T>(
|
||||
start: () => Promise<T>,
|
||||
ms: number,
|
||||
fallback: T,
|
||||
): Promise<TimeoutEnvelope<T>> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let settled = false;
|
||||
|
||||
return new Promise<TimeoutEnvelope<T>>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ timedOut: true, value: fallback });
|
||||
}, ms);
|
||||
|
||||
start()
|
||||
.then((value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolve({ timedOut: false, value });
|
||||
})
|
||||
.catch(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
// Upstream errored before the timeout fired — surface the
|
||||
// fallback so the route still returns a deterministic shape
|
||||
// and the caller does not have to learn another failure mode.
|
||||
resolve({ timedOut: true, value: fallback });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The 20-second cap shipped in v1.4.28. Provider round-trips that
|
||||
* exceed this budget are converted to a cached-fallback render with a
|
||||
* "couldn't reach provider" caption on the InsightStatusCard.
|
||||
*/
|
||||
export const STATUS_PROVIDER_TIMEOUT_MS = 20_000;
|
||||
Reference in new issue
Block a user