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:
Marc-André Bombeck committed 2026-05-16 00:28:31 +02:00
1 parent b00be2863d
commit 0d591ac928
10 files changed
+540 -174

No files matched your search

+5
View File
@@ -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();
});
});
+63 -30
View File
@@ -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");
+49 -22
View File
@@ -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");
+61 -30
View File
@@ -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(
+66 -32
View File
@@ -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");
+54 -19
View File
@@ -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");
+61 -30
View File
@@ -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");
+71
View File
@@ -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;