Better error handling
This commit is contained in:
1 parent
78e99fc415
commit
214a9ef714
6 files changed
+412
-33
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user