Better error handling

This commit is contained in:
Lucas Winther
2026-07-29 07:59:16 +02:00
parent 78e99fc415
commit 214a9ef714
6 changed files with 412 additions and 33 deletions
+98 -31
View File
@@ -11,6 +11,7 @@ import { StorageManager } from "./components/StorageManager";
import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { decodeAudioFile } from "./lib/audio";
import { classifyError, type ClassifiedError } from "./lib/errors";
import {
BrowserEngine,
ServerEngine,
@@ -54,6 +55,17 @@ interface TranscriptResult {
chunks: TranscriptChunk[];
}
/**
* Displayed error state (M6 error taxonomy, `src/lib/errors.ts`). `retryKind`
* says which retry affordance (if any) the error box should offer — computed
* at render time against current component state, not baked into a closure
* captured when the worker's message handler was first registered.
*/
interface AppErrorState {
classified: ClassifiedError;
retryKind?: "load" | "file";
}
const MODEL_IDS = MODELS.map((model) => model.id);
export function App() {
@@ -82,6 +94,11 @@ export function App() {
// audio to the (fresh) worker. Decoding itself cannot be aborted.
const fileRun = useRef(0);
// Last file handed to handleFile, so a retryable transcribe-stage error
// (network failure talking to the server engine) can offer a Retry button
// without holding onto File objects in state.
const lastFileRef = useRef<File | null>(null);
// Accelerator failures learned this session (D-025), keyed
// "modelId::preference" → the explicit device to load with instead, so
// repeat loads skip the doomed accelerator attempt (and its restart)
@@ -124,7 +141,7 @@ export function App() {
totalSeconds: number;
} | null>(null);
const [result, setResult] = useState<TranscriptResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AppErrorState | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [liveStatus, setLiveStatus] = useState<LiveStatus>("idle");
@@ -201,6 +218,11 @@ export function App() {
};
}, [auth.user]);
/** `navigator.onLine`, guarded for non-browser test environments. */
function isOffline(): boolean {
return typeof navigator !== "undefined" && !navigator.onLine;
}
function overrideKey(forModelId: string, preference: DevicePreference): string {
return `${forModelId}::${preference}`;
}
@@ -358,7 +380,14 @@ export function App() {
if (message.stage === "load") {
loadGate.current?.reject(new Error(message.message));
}
setError(message.message);
const classified = classifyError(message.message, {
stage: message.stage,
offline: isOffline(),
});
setError({
classified,
retryKind: classified.retryable ? (message.stage === "load" ? "load" : "file") : undefined,
});
setTranscribeProgress(null);
setBusy(false);
setRunEngine(null);
@@ -405,14 +434,20 @@ export function App() {
}
});
worker.addEventListener("error", (event) => {
const failure = new Error(
event.message || "The inference worker crashed unexpectedly.",
);
// Classify on the raw (possibly empty) message: an empty message is
// itself the signal for a silent OOM tab-kill (D-027 — no JS error
// event carries details when that happens), which a fallback string
// would otherwise hide from the classifier.
const classified = classifyError(event.message, {
stage: "worker-crash",
offline: isOffline(),
});
const failure = new Error(classified.message);
// Unblock any live passes so a live session can drain and stop.
for (const pending of pendingLive.current.values()) pending.reject(failure);
pendingLive.current.clear();
loadGate.current?.reject(failure);
setError(failure.message);
setError({ classified, retryKind: classified.retryable ? "load" : undefined });
setBusy(false);
});
workerRef.current = worker;
@@ -534,6 +569,7 @@ export function App() {
async function handleFile(file: File) {
if (busy || liveStatus !== "idle") return;
lastFileRef.current = file;
// The toggle's choice only takes effect with a session and an available
// server engine (D-010); everything else runs on-device as always.
const useServer =
@@ -587,9 +623,7 @@ export function App() {
audio = await decodeAudioFile(file);
} catch (decodeError) {
if (fileRun.current === run) {
setError(
decodeError instanceof Error ? decodeError.message : String(decodeError),
);
setError({ classified: classifyError(decodeError, { stage: "decode" }) });
setBusy(false);
setRunEngine(null);
}
@@ -608,7 +642,8 @@ export function App() {
await browserModelReady();
} catch (loadError) {
if (fileRun.current !== run) return; // cancelled while loading
setError(loadError instanceof Error ? loadError.message : String(loadError));
const classified = classifyError(loadError, { stage: "load", offline: isOffline() });
setError({ classified, retryKind: classified.retryable ? "load" : undefined });
setBusy(false);
setRunEngine(null);
return;
@@ -645,11 +680,11 @@ export function App() {
setRunEngine(null);
} catch (transcribeError) {
if (fileRun.current !== run) return; // cancellation rejections are expected
setError(
transcribeError instanceof Error
? transcribeError.message
: String(transcribeError),
);
const classified = classifyError(transcribeError, {
stage: "transcribe",
offline: isOffline(),
});
setError({ classified, retryKind: classified.retryable ? "file" : undefined });
setTranscribeProgress(null);
setBusy(false);
setRunEngine(null);
@@ -726,7 +761,7 @@ export function App() {
setLiveCommitted("");
setLiveTentative("");
setLiveStatus("idle");
if (message) setError(message);
if (message) setError({ classified: classifyError(message) });
};
ws.onmessage = (event) => {
@@ -748,7 +783,7 @@ export function App() {
setLiveCommitted(update.committedText);
setLiveTentative(update.tentativeText);
setLivePending(update.pendingFinalPasses ?? 0);
if (update.error) setError(update.error);
if (update.error) setError({ classified: classifyError(update.error) });
} else if (data.type === "result") {
setResult(data.result as TranscriptResult);
endSession(null);
@@ -762,9 +797,12 @@ export function App() {
// does the actual cleanup — this only needs to supply the message
// (onclose can't distinguish a clean vs. abrupt closure on its own).
if (liveWsRef.current === ws) {
setError(
"Lost connection to the server during live transcription. The microphone has been stopped.",
);
setError({
classified: classifyError(
"Lost connection to the server during live transcription. The microphone has been stopped.",
{ offline: isOffline() },
),
});
}
};
@@ -798,13 +836,13 @@ export function App() {
} catch (micError) {
ws.close();
setLiveStatus("idle");
setError(
const message =
micError instanceof DOMException && micError.name === "NotAllowedError"
? "Microphone access was denied. Allow it in your browser's site settings and try again."
: micError instanceof Error
? micError.message
: String(micError),
);
: String(micError);
setError({ classified: classifyError(message) });
}
};
return;
@@ -832,7 +870,7 @@ export function App() {
setLiveCommitted(update.committedText);
setLiveTentative(update.tentativeText);
setLivePending(update.pendingFinalPasses);
if (update.error) setError(update.error);
if (update.error) setError({ classified: classifyError(update.error) });
},
});
@@ -842,13 +880,13 @@ export function App() {
setLiveStatus("listening");
} catch (micError) {
setLiveStatus("idle");
setError(
const message =
micError instanceof DOMException && micError.name === "NotAllowedError"
? "Microphone access was denied. Allow it in your browser's site settings and try again."
: micError instanceof Error
? micError.message
: String(micError),
);
: String(micError);
setError({ classified: classifyError(message) });
}
}
@@ -871,7 +909,12 @@ export function App() {
liveWsRef.current = null;
liveRef.current = null;
setLiveStatus("idle");
setError("Live transcription lost its connection to the server before it could finish.");
setError({
classified: classifyError(
"Live transcription lost its connection to the server before it could finish.",
{ offline: isOffline() },
),
});
}
} else {
try {
@@ -1013,9 +1056,33 @@ export function App() {
)}
{error && (
<p className="error" role="alert">
{error}
</p>
<div className="error" role="alert">
<p className="error-message">{error.classified.message}</p>
{error.classified.suggestion && (
<p className="error-suggestion">{error.classified.suggestion}</p>
)}
{error.retryKind === "load" && (
<button
type="button"
className="error-retry-button"
onClick={() => startLoad(modelId, devicePreference)}
>
Retry
</button>
)}
{error.retryKind === "file" && lastFileRef.current && (
<button
type="button"
className="error-retry-button"
onClick={() => {
const file = lastFileRef.current;
if (file) void handleFile(file);
}}
>
Retry
</button>
)}
</div>
)}
{showModelProgress && (
+27
View File
@@ -546,6 +546,33 @@ body {
color: #f2b8b5;
}
.error-message {
margin: 0;
}
.error-suggestion {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: #d99a97;
}
.error-retry-button {
margin-top: 0.6rem;
border: 1px solid #f2b8b5;
background: transparent;
color: #f2b8b5;
border-radius: 8px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.error-retry-button:hover {
background: #f2b8b5;
color: #2a1c1e;
}
/* --- ModelLoadProgress --- */
.model-progress {
+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 };
}