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