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
+53
View File
@@ -702,3 +702,56 @@ future model's peak lands awkwardly relative to the 0.1 fraction).
is no real crash-telemetry feedback loop. If field reports show 0.1 is still is no real crash-telemetry feedback loop. If field reports show 0.1 is still
too generous (or too aggressive) for some device class, adjust the constant too generous (or too aggressive) for some device class, adjust the constant
rather than the shape of the heuristic. rather than the shape of the heuristic.
## D-030 — Error taxonomy: pattern-matched classification, not structured error types
**Status:** Accepted (2026-07-29)
Every failure path (decode, model load, transcription, a crashed worker, a
live session) used to end at `setError(rawMessage)`, rendered as one plain
string — no distinction between "this file won't decode" (try another file),
"the tab ran out of memory" (try a smaller model), a lost WebGPU device
(switch backend), or a dropped network connection mid-download (retry). The
WorkBreakdown M6 item asked for exactly those four categories.
**Shape:** `src/lib/errors.ts` exports a pure `classifyError(raw, context)`:
`context.stage` (`"decode" | "load" | "transcribe" | "worker-crash"`) plus
`context.offline` (`navigator.onLine`) disambiguate cases the message text
alone can't, then substring pattern-matching on the message sorts the rest
into `unsupported-file` / `out-of-memory` / `device-lost` / `network` /
`generic`, each with a user-facing `message`, an optional actionable
`suggestion`, and `retryable`. **Why pattern-matching, not typed errors from
each layer:** the failures cross Web Audio, Transformers.js, onnxruntime-web
(WASM and the WebGPU/WebNN JSEP EPs), and `fetch` — none of which expose a
shared error hierarchy the app controls, so there is nothing to `instanceof`
against; the only thing every layer reliably produces is an `Error` (or
DOMException) with a message string. A worker crash (`Worker.onerror`) with
an **empty** message is itself classified as out-of-memory: D-027 found that
the browser's silent OOM tab-kill fires no JS error event with details at
all, so an empty message *is* the OOM signal in that one context — the
classifier only applies that inference when `context.stage ===
"worker-crash"`, not to an empty message anywhere else.
**UI wiring (`src/App.tsx`):** the `error` state changed from a `string` to
`{ classified: ClassifiedError; retryKind?: "load" | "file" }`. `retryKind`
is resolved to an actual retry action **at render time** (`onClick={() =>
startLoad(modelId, devicePreference)}` / replaying `lastFileRef.current`
through `handleFile`), reading current component state directly — not
computed inside the worker's `message`/`error` event listeners, which are
registered once per worker lifetime (`ensureWorker`) and would otherwise
close over whichever render's `modelId`/`devicePreference` happened to be
current at registration, going stale on every later model/device switch.
**Consequence:** only `network` and `device-lost` are `retryable` (an
unsupported file or an OOM won't succeed by retrying the identical action);
the error box shows a Retry button only for those, alongside the suggestion
text. Live-mode errors (mic permission, WS disconnects) still classify
through the same function for a consistent error box, but never carry
`retryKind` — the existing Start/Stop controls are already the retry path
there.
**Alternatives rejected:** wrapping each subsystem's calls in typed error
classes at the boundary (real fix, but Transformers.js/onnxruntime-web
throw plain `Error`s from deep internals — retrofitting types there means
forking dependencies, out of scope); classifying in the worker instead of
the UI (the worker already forwards a string message over postMessage —
classifying there would just move the same pattern-matching one hop over
for no benefit, and the UI is where `context.offline`/retry actions live).
**Revisit:** if a future onnxruntime-web/Transformers.js version starts
surfacing richer structured errors (e.g. a `GPUDeviceLostInfo` object
instead of a stringified message), prefer inspecting that directly over
adding more substring patterns.
+8 -2
View File
@@ -218,8 +218,14 @@ Notes (M5):
exercised for real (browser half still needs the manual run). exercised for real (browser half still needs the manual run).
## M6 — Hardening & polish ## M6 — Hardening & polish
- [ ] Error taxonomy: unsupported file, out-of-memory (suggest smaller model), - [x] Error taxonomy: unsupported file, out-of-memory (suggest smaller model),
WebGPU device-lost, network loss mid-download (resume or clear retry) WebGPU device-lost, network loss mid-download (resume or clear retry) —
pure `classifyError` in `src/lib/errors.ts` (D-030) sorts every failure
path (decode, load, transcribe, a crashed worker) into one of the four
categories plus a generic fallback, each with a user-facing message and
an actionable suggestion; the error box in `App.tsx` adds a Retry
button for the two retryable kinds (network, device-lost), replaying
the failed load or the last file. Unit-tested in `tests/errors.test.ts`.
- [x] Cancel in-flight transcription (both engines) — browser engine via - [x] Cancel in-flight transcription (both engines) — browser engine via
worker termination (D-018), server engine via aborting the worker termination (D-018), server engine via aborting the
`/api/transcribe` fetch (D-018 amendment; landed with M5's EngineToggle) `/api/transcribe` fetch (D-018 amendment; landed with M5's EngineToggle)
+98 -31
View File
@@ -11,6 +11,7 @@ import { StorageManager } from "./components/StorageManager";
import { TranscriptView } from "./components/TranscriptView"; import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions"; import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { decodeAudioFile } from "./lib/audio"; import { decodeAudioFile } from "./lib/audio";
import { classifyError, type ClassifiedError } from "./lib/errors";
import { import {
BrowserEngine, BrowserEngine,
ServerEngine, ServerEngine,
@@ -54,6 +55,17 @@ interface TranscriptResult {
chunks: TranscriptChunk[]; 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); const MODEL_IDS = MODELS.map((model) => model.id);
export function App() { export function App() {
@@ -82,6 +94,11 @@ export function App() {
// audio to the (fresh) worker. Decoding itself cannot be aborted. // audio to the (fresh) worker. Decoding itself cannot be aborted.
const fileRun = useRef(0); 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 // Accelerator failures learned this session (D-025), keyed
// "modelId::preference" → the explicit device to load with instead, so // "modelId::preference" → the explicit device to load with instead, so
// repeat loads skip the doomed accelerator attempt (and its restart) // repeat loads skip the doomed accelerator attempt (and its restart)
@@ -124,7 +141,7 @@ export function App() {
totalSeconds: number; totalSeconds: number;
} | null>(null); } | null>(null);
const [result, setResult] = useState<TranscriptResult | 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 [fileName, setFileName] = useState<string | null>(null);
const [liveStatus, setLiveStatus] = useState<LiveStatus>("idle"); const [liveStatus, setLiveStatus] = useState<LiveStatus>("idle");
@@ -201,6 +218,11 @@ export function App() {
}; };
}, [auth.user]); }, [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 { function overrideKey(forModelId: string, preference: DevicePreference): string {
return `${forModelId}::${preference}`; return `${forModelId}::${preference}`;
} }
@@ -358,7 +380,14 @@ export function App() {
if (message.stage === "load") { if (message.stage === "load") {
loadGate.current?.reject(new Error(message.message)); 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); setTranscribeProgress(null);
setBusy(false); setBusy(false);
setRunEngine(null); setRunEngine(null);
@@ -405,14 +434,20 @@ export function App() {
} }
}); });
worker.addEventListener("error", (event) => { worker.addEventListener("error", (event) => {
const failure = new Error( // Classify on the raw (possibly empty) message: an empty message is
event.message || "The inference worker crashed unexpectedly.", // 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. // Unblock any live passes so a live session can drain and stop.
for (const pending of pendingLive.current.values()) pending.reject(failure); for (const pending of pendingLive.current.values()) pending.reject(failure);
pendingLive.current.clear(); pendingLive.current.clear();
loadGate.current?.reject(failure); loadGate.current?.reject(failure);
setError(failure.message); setError({ classified, retryKind: classified.retryable ? "load" : undefined });
setBusy(false); setBusy(false);
}); });
workerRef.current = worker; workerRef.current = worker;
@@ -534,6 +569,7 @@ export function App() {
async function handleFile(file: File) { async function handleFile(file: File) {
if (busy || liveStatus !== "idle") return; if (busy || liveStatus !== "idle") return;
lastFileRef.current = file;
// The toggle's choice only takes effect with a session and an available // The toggle's choice only takes effect with a session and an available
// server engine (D-010); everything else runs on-device as always. // server engine (D-010); everything else runs on-device as always.
const useServer = const useServer =
@@ -587,9 +623,7 @@ export function App() {
audio = await decodeAudioFile(file); audio = await decodeAudioFile(file);
} catch (decodeError) { } catch (decodeError) {
if (fileRun.current === run) { if (fileRun.current === run) {
setError( setError({ classified: classifyError(decodeError, { stage: "decode" }) });
decodeError instanceof Error ? decodeError.message : String(decodeError),
);
setBusy(false); setBusy(false);
setRunEngine(null); setRunEngine(null);
} }
@@ -608,7 +642,8 @@ export function App() {
await browserModelReady(); await browserModelReady();
} catch (loadError) { } catch (loadError) {
if (fileRun.current !== run) return; // cancelled while loading 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); setBusy(false);
setRunEngine(null); setRunEngine(null);
return; return;
@@ -645,11 +680,11 @@ export function App() {
setRunEngine(null); setRunEngine(null);
} catch (transcribeError) { } catch (transcribeError) {
if (fileRun.current !== run) return; // cancellation rejections are expected if (fileRun.current !== run) return; // cancellation rejections are expected
setError( const classified = classifyError(transcribeError, {
transcribeError instanceof Error stage: "transcribe",
? transcribeError.message offline: isOffline(),
: String(transcribeError), });
); setError({ classified, retryKind: classified.retryable ? "file" : undefined });
setTranscribeProgress(null); setTranscribeProgress(null);
setBusy(false); setBusy(false);
setRunEngine(null); setRunEngine(null);
@@ -726,7 +761,7 @@ export function App() {
setLiveCommitted(""); setLiveCommitted("");
setLiveTentative(""); setLiveTentative("");
setLiveStatus("idle"); setLiveStatus("idle");
if (message) setError(message); if (message) setError({ classified: classifyError(message) });
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -748,7 +783,7 @@ export function App() {
setLiveCommitted(update.committedText); setLiveCommitted(update.committedText);
setLiveTentative(update.tentativeText); setLiveTentative(update.tentativeText);
setLivePending(update.pendingFinalPasses ?? 0); setLivePending(update.pendingFinalPasses ?? 0);
if (update.error) setError(update.error); if (update.error) setError({ classified: classifyError(update.error) });
} else if (data.type === "result") { } else if (data.type === "result") {
setResult(data.result as TranscriptResult); setResult(data.result as TranscriptResult);
endSession(null); endSession(null);
@@ -762,9 +797,12 @@ export function App() {
// does the actual cleanup — this only needs to supply the message // does the actual cleanup — this only needs to supply the message
// (onclose can't distinguish a clean vs. abrupt closure on its own). // (onclose can't distinguish a clean vs. abrupt closure on its own).
if (liveWsRef.current === ws) { if (liveWsRef.current === ws) {
setError( setError({
"Lost connection to the server during live transcription. The microphone has been stopped.", 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) { } catch (micError) {
ws.close(); ws.close();
setLiveStatus("idle"); setLiveStatus("idle");
setError( const message =
micError instanceof DOMException && micError.name === "NotAllowedError" micError instanceof DOMException && micError.name === "NotAllowedError"
? "Microphone access was denied. Allow it in your browser's site settings and try again." ? "Microphone access was denied. Allow it in your browser's site settings and try again."
: micError instanceof Error : micError instanceof Error
? micError.message ? micError.message
: String(micError), : String(micError);
); setError({ classified: classifyError(message) });
} }
}; };
return; return;
@@ -832,7 +870,7 @@ export function App() {
setLiveCommitted(update.committedText); setLiveCommitted(update.committedText);
setLiveTentative(update.tentativeText); setLiveTentative(update.tentativeText);
setLivePending(update.pendingFinalPasses); 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"); setLiveStatus("listening");
} catch (micError) { } catch (micError) {
setLiveStatus("idle"); setLiveStatus("idle");
setError( const message =
micError instanceof DOMException && micError.name === "NotAllowedError" micError instanceof DOMException && micError.name === "NotAllowedError"
? "Microphone access was denied. Allow it in your browser's site settings and try again." ? "Microphone access was denied. Allow it in your browser's site settings and try again."
: micError instanceof Error : micError instanceof Error
? micError.message ? micError.message
: String(micError), : String(micError);
); setError({ classified: classifyError(message) });
} }
} }
@@ -871,7 +909,12 @@ export function App() {
liveWsRef.current = null; liveWsRef.current = null;
liveRef.current = null; liveRef.current = null;
setLiveStatus("idle"); 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 { } else {
try { try {
@@ -1013,9 +1056,33 @@ export function App() {
)} )}
{error && ( {error && (
<p className="error" role="alert"> <div className="error" role="alert">
{error} <p className="error-message">{error.classified.message}</p>
</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 && ( {showModelProgress && (
+27
View File
@@ -546,6 +546,33 @@ body {
color: #f2b8b5; 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 --- */ /* --- ModelLoadProgress --- */
.model-progress { .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 };
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, test, expect } from "bun:test";
import { classifyError } from "../src/lib/errors";
// Pure pattern-matching (M6 error taxonomy) — no browser needed.
describe("classifyError", () => {
test("decode-stage failures are always unsupported-file, regardless of message", () => {
const result = classifyError(new Error("Unable to decode audio data"), { stage: "decode" });
expect(result.kind).toBe("unsupported-file");
expect(result.retryable).toBe(false);
expect(result.suggestion).toMatch(/different file|convert/i);
});
test("recognizes out-of-memory messages", () => {
for (const message of [
"RuntimeError: Aborted(OOM)",
"Cannot enlarge memory arrays to size 123456, (OOM)",
"Failed to allocate memory for tensor",
"could not allocate buffer",
]) {
const result = classifyError(new Error(message));
expect(result.kind).toBe("out-of-memory");
expect(result.retryable).toBe(false);
expect(result.suggestion).toMatch(/smaller model/i);
}
});
test("a worker crash with an empty message is treated as out-of-memory (D-027)", () => {
const result = classifyError(new Error(""), { stage: "worker-crash" });
expect(result.kind).toBe("out-of-memory");
expect(result.message.length).toBeGreaterThan(0);
});
test("a worker crash with a real message is not assumed to be OOM", () => {
const result = classifyError(new Error("TypeError: x is not a function"), {
stage: "worker-crash",
});
expect(result.kind).toBe("generic");
});
test("recognizes WebGPU device-lost messages", () => {
for (const message of [
"GPUDeviceLostInfo: device was lost",
"WebGPU device was lost: reason=destroyed",
"the GPU device has been lost",
]) {
const result = classifyError(new Error(message));
expect(result.kind).toBe("device-lost");
expect(result.retryable).toBe(true);
expect(result.suggestion).toMatch(/backend|try again/i);
}
});
test("recognizes network failures", () => {
for (const message of [
"TypeError: Failed to fetch",
"NetworkError when attempting to fetch resource.",
"Load failed",
]) {
const result = classifyError(new Error(message), { stage: "load" });
expect(result.kind).toBe("network");
expect(result.retryable).toBe(true);
}
});
test("offline context forces a network classification with a clear message", () => {
const result = classifyError(new Error("something obscure"), {
stage: "load",
offline: true,
});
expect(result.kind).toBe("network");
expect(result.retryable).toBe(true);
expect(result.message).toMatch(/offline/i);
expect(result.suggestion).toMatch(/restart/i);
});
test("load-stage network suggestion mentions the download restarting", () => {
const result = classifyError(new Error("Failed to fetch"), { stage: "load" });
expect(result.suggestion).toMatch(/restart/i);
});
test("transcribe-stage network suggestion is stage-agnostic", () => {
const result = classifyError(new Error("Failed to fetch"), { stage: "transcribe" });
expect(result.suggestion).not.toMatch(/restart/i);
});
test("falls back to a generic, non-retryable classification", () => {
const result = classifyError(new Error("Something went wrong doing the thing"));
expect(result).toEqual({
kind: "generic",
message: "Something went wrong doing the thing",
retryable: false,
});
});
test("accepts non-Error raw values", () => {
expect(classifyError("plain string failure").message).toBe("plain string failure");
expect(classifyError(42).message).toBe("42");
});
});