Better error handling
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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 && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user