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
too generous (or too aggressive) for some device class, adjust the constant
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).
## M6 — Hardening & polish
- [ ] Error taxonomy: unsupported file, out-of-memory (suggest smaller model),
WebGPU device-lost, network loss mid-download (resume or clear retry)
- [x] Error taxonomy: unsupported file, out-of-memory (suggest smaller model),
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
worker termination (D-018), server engine via aborting the
`/api/transcribe` fetch (D-018 amendment; landed with M5's EngineToggle)