mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
The per-card status path capped provider generation at 20 s and, on timeout, persisted the generic no-key fallback as a day-keyed cache row. The cache-read only checked the day key and text, so that stub looked like a real assessment and stuck until midnight — hiding the real, data-driven text for the rest of the day on any account that hit a single transient stall. Align the budget with the provider clients' 60 s ceiling, route the status path through resolveProviderChain + runRawCompletionWithFallback so a degraded provider cascades like the coach, and stop persisting the fallback: a timeout or error is a transient miss that serves the fallback for the current render and re-attempts on the next mount. A shared cache-read rejects any lingering timeout-stub rows by their markers, and withTimeout now annotates real errors instead of folding them silently into the timeout path.
90 lines
3.1 KiB
TypeScript
90 lines
3.1 KiB
TypeScript
import { annotate } from "@/lib/logging/context";
|
|
|
|
/**
|
|
* Race an awaited promise against a hard timeout.
|
|
*
|
|
* The seven `*-status.ts` routes await `provider.generateCompletion()`
|
|
* and need an upper bound so a stalled provider does not pin the
|
|
* InsightStatusCard behind React-Query's retries. `withTimeout` resolves
|
|
* with the wrapped promise's value when it lands inside the budget, or
|
|
* the `fallback` value otherwise.
|
|
*
|
|
* Returns a typed envelope so the call site can distinguish a real
|
|
* timeout from an upstream error from a normal value, and react
|
|
* differently to each — a timeout/error must NOT be persisted as a
|
|
* day-long cache entry the way a real assessment is.
|
|
*
|
|
* The budget matches the provider clients' own 60 s ceiling. The
|
|
* earlier 20 s cap fired below the providers' floor on cold starts and
|
|
* model warm-ups, converting healthy-but-slow generations into the
|
|
* generic fallback; aligning the two lets a slow-but-valid round-trip
|
|
* land instead of being discarded.
|
|
*/
|
|
export interface TimeoutEnvelope<T> {
|
|
/** True when the budget expired before the upstream settled. */
|
|
timedOut: boolean;
|
|
/** True when the upstream rejected before the budget expired. */
|
|
errored: 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;
|
|
annotate({
|
|
action: { name: "insights.status.provider_timeout" },
|
|
meta: { timeout_ms: ms },
|
|
});
|
|
resolve({ timedOut: true, errored: false, value: fallback });
|
|
}, ms);
|
|
|
|
start()
|
|
.then((value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
resolve({ timedOut: false, errored: false, value });
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
// Upstream errored before the timeout fired. Surface it as a
|
|
// distinct `errored` envelope and annotate the reason so a real
|
|
// provider failure is observable in the wide events instead of
|
|
// being silently swallowed into the timeout path.
|
|
const reason =
|
|
error instanceof Error ? error.message : String(error ?? "unknown");
|
|
annotate({
|
|
action: { name: "insights.status.provider_error" },
|
|
meta: { reason: reason.slice(0, 240) },
|
|
});
|
|
resolve({ timedOut: false, errored: true, value: fallback });
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Status-path provider budget, aligned with the provider clients' own
|
|
* 60 s ceiling. A round-trip that exceeds this is treated as a transient
|
|
* miss — the caller returns the deterministic fallback for this render
|
|
* WITHOUT persisting it as the day's assessment, so the next mount
|
|
* re-attempts a real generation.
|
|
*/
|
|
export const STATUS_PROVIDER_TIMEOUT_MS = 60_000;
|