fix(insights): derive briefing warm budget from the response timeout

The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.

Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.

Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.

Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 15:08:04 +02:00
1 parent 4266203d3a
commit 4ad4707be9
18 files changed
+426 -41

No files matched your search

+24 -2
View File
@@ -319,8 +319,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
// briefing is held rather than freshly refreshed — and, when there is no
// last good text, that the empty state should read "couldn't generate" with
// a retry rather than the generic "no briefing yet".
const generationFailed =
(await readBriefingFailure({ userId, since: cachedAt })) !== null;
const briefingFailure = await readBriefingFailure({
userId,
since: cachedAt,
});
const generationFailed = briefingFailure !== null;
// v1.25.3 — the failure class lets the empty state point its hint at the
// right lever (raise the response timeout vs re-check the provider).
const generationFailureClass = briefingFailure?.failureClass ?? null;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -368,6 +374,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
hasProvider,
// v1.25 — true when the last generation attempt failed (held text).
generationFailed,
// v1.25.3 — coarse failure class for the empty-state hint (null when
// the last attempt succeeded).
generationFailureClass,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -390,6 +399,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.25 — no last-good text AND the last attempt failed: the UI shows a
// "couldn't generate" empty state with a retry instead of the generic one.
generationFailed,
// v1.25.3 — coarse failure class for the empty-state hint.
generationFailureClass,
});
});
@@ -760,6 +771,16 @@ export const POST = apiHandler((request: NextRequest) =>
// path, so the last good briefing stays intact). The read path pairs the
// preserved text with a discreet "couldn't refresh" hint, or shows a
// retry empty state when there is no last good text.
// v1.25.3 — carry the upstream status so the read-path hint can tell an
// auth / rate-limit failure apart from a plain timeout. For an
// all-providers-failed error the first hop's status is the most
// representative; a single provider error exposes its own `httpStatus`.
const failureHttpStatus =
e instanceof AllProvidersFailedError
? (e.attempts[0]?.httpStatus ?? null)
: typeof (e as { httpStatus?: number }).httpStatus === "number"
? (e as { httpStatus?: number }).httpStatus
: null;
void recordBriefingFailure({
userId,
reason:
@@ -767,6 +788,7 @@ export const POST = apiHandler((request: NextRequest) =>
? "all-providers-failed"
: "provider-error",
locale,
httpStatus: failureHttpStatus,
});
if (e instanceof AllProvidersFailedError) {
annotate({