Better error handling

This commit is contained in:
Lucas Winther committed 2026-07-29 07:59:16 +02:00
1 parent 78e99fc415
commit 214a9ef714
6 files changed
+412 -33

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
/**
* Error taxonomy for user-facing failures (WorkBreakdown M6: "Error
* taxonomy: unsupported file, out-of-memory (suggest smaller model), WebGPU
* device-lost, network loss mid-download").
*
* Pure and unit-tested — turns a raw `Error`/string/unknown thrown somewhere
* in the app (worker load/transcribe, decode, a crashed worker, a failed
* fetch) into a `ClassifiedError` the UI can render consistently: a message,
* an optional actionable suggestion, and whether a Retry button makes sense.
*
* Classification is pattern-matching on the error message plus a `stage`
* hint from the caller (decode errors are always "unsupported file"; a
* worker crash with no message at all is almost always the browser's silent
* OOM tab-kill, per Decisions.md D-027's finding that no JS `error` event
* carries details when that happens) — there is no structured error type to
* inspect across Web Audio / Transformers.js / onnxruntime-web / fetch.
*/
export type ErrorStage = "decode" | "load" | "transcribe" | "worker-crash";
export type ErrorKind = "unsupported-file" | "out-of-memory" | "device-lost" | "network" | "generic";
export interface ClassifiedError {
kind: ErrorKind;
message: string;
/** Actionable next step to show alongside the message, if any. */
suggestion?: string;
/** Whether retrying the same action stands a reasonable chance of working. */
retryable: boolean;
}
export interface ClassifyContext {
/** Which part of the pipeline the error came from. */
stage?: ErrorStage;
/** `navigator.onLine` at classification time, if known. */
offline?: boolean;
}
function toMessage(raw: unknown): string {
return raw instanceof Error ? raw.message : String(raw);
}
function matchesAny(haystack: string, needles: readonly string[]): boolean {
const lower = haystack.toLowerCase();
return needles.some((needle) => lower.includes(needle));
}
// Substrings observed (or documented upstream) for each failure mode. Case
// folded via matchesAny, so mixed-case engine messages still match.
const OOM_PATTERNS = [
"out of memory",
"(oom)",
"failed to allocate",
"could not allocate",
"allocation failed",
"cannot enlarge memory",
];
const DEVICE_LOST_PATTERNS = [
"device was lost",
"device lost",
"device has been lost",
"lost the gpu device",
"gpudevicelostinfo",
];
const NETWORK_PATTERNS = [
"failed to fetch",
"networkerror",
"network connection was lost",
"network request failed",
"err_internet_disconnected",
"err_network",
"err_connection",
"load failed", // Safari's fetch-failure message is literally "Load failed"
];
/** Classify a raw thrown value into a `ClassifiedError` for display. */
export function classifyError(raw: unknown, context: ClassifyContext = {}): ClassifiedError {
const message = toMessage(raw);
if (context.stage === "decode") {
return {
kind: "unsupported-file",
message,
suggestion:
"Try converting the file to a common format (WAV, MP3, M4A) or pick a different file.",
retryable: false,
};
}
const trimmed = message.trim();
if (matchesAny(message, OOM_PATTERNS) || (context.stage === "worker-crash" && trimmed.length === 0)) {
return {
kind: "out-of-memory",
message: trimmed.length > 0 ? message : "The browser ran out of memory and had to stop.",
suggestion:
"Try a smaller model (Tiny, Base, or Small) — it needs far less memory to load and run.",
retryable: false,
};
}
if (matchesAny(message, DEVICE_LOST_PATTERNS)) {
return {
kind: "device-lost",
message,
suggestion:
"Your GPU driver reset or the browser lost its graphics context. Switch the Backend to WASM, or try again.",
retryable: true,
};
}
if (matchesAny(message, NETWORK_PATTERNS) || context.offline) {
return {
kind: "network",
message: context.offline ? "You appear to be offline. Check your connection and try again." : message,
suggestion:
context.stage === "load"
? "Check your connection, then retry — the model download will restart."
: "Check your connection and try again.",
retryable: true,
};
}
return { kind: "generic", message, retryable: false };
}