feat(error): add error handling foundation (Day 0)

- extractErrorMessage(err: unknown) normalizes any error shape to string
- handleError/handleInfraError dual utilities (user-facing vs infra-only)
- error-classifier extended with ipc/database/filesystem types (9 total)
- Toast rate-limiting (max 3 per type per 30s) in notifications store
- Infrastructure bridges use documented console.warn (recursion prevention)
- 13 new tests for extractErrorMessage
This commit is contained in:
Hibryda 2026-03-18 01:19:23 +01:00
parent bfc01192d2
commit dcdb741403
7 changed files with 215 additions and 4 deletions

View file

@ -13,7 +13,10 @@ export function sendDesktopNotification(
body: string,
urgency: NotificationUrgency = 'normal',
): void {
invoke('notify_desktop', { title, body, urgency }).catch(() => {
// Swallow IPC errors — notifications must never break the app
invoke('notify_desktop', { title, body, urgency }).catch((_e: unknown) => {
// Intentional: notification daemon may not be running. Cannot use handleInfraError
// here — it calls tel.error, and notify() calls sendDesktopNotification, creating a loop.
// eslint-disable-next-line no-console
console.warn('[notifications-bridge] Desktop notification failed:', _e);
});
}

View file

@ -11,8 +11,11 @@ export function telemetryLog(
message: string,
context?: Record<string, unknown>,
): void {
invoke('frontend_log', { level, message, context: context ?? null }).catch(() => {
// Swallow IPC errors — telemetry must never break the app
invoke('frontend_log', { level, message, context: context ?? null }).catch((_e: unknown) => {
// Intentional: telemetry must never break the app or trigger notification loops.
// Cannot use handleInfraError here — it calls tel.error which would recurse.
// eslint-disable-next-line no-console
console.warn('[telemetry-bridge] IPC failed:', _e);
});
}

View file

@ -42,6 +42,22 @@ const MAX_TOASTS = 5;
const TOAST_DURATION_MS = 4000;
const MAX_HISTORY = 100;
// --- Rate limiting (prevents toast flood from hot paths) ---
const RATE_LIMIT_WINDOW_MS = 30_000;
const RATE_LIMIT_MAX_PER_TYPE = 3;
const recentToasts = new Map<ToastType, number[]>();
function isRateLimited(type: ToastType): boolean {
const now = Date.now();
const timestamps = recentToasts.get(type) ?? [];
const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
recentToasts.set(type, recent);
if (recent.length >= RATE_LIMIT_MAX_PER_TYPE) return true;
recent.push(now);
return false;
}
// --- Toast API (preserved from original) ---
export function getNotifications(): Toast[] {
@ -49,6 +65,8 @@ export function getNotifications(): Toast[] {
}
export function notify(type: ToastType, message: string): string {
if (isRateLimited(type)) return '';
const id = crypto.randomUUID();
toasts.push({ id, type, message, timestamp: Date.now() });

View file

@ -6,6 +6,9 @@ export type ApiErrorType =
| 'quota'
| 'overloaded'
| 'network'
| 'ipc'
| 'database'
| 'filesystem'
| 'unknown';
export interface ClassifiedError {
@ -59,6 +62,36 @@ const NETWORK_PATTERNS = [
/dns/i,
];
const IPC_PATTERNS = [
/ipc.?error/i,
/plugin.?not.?found/i,
/command.?not.?found/i,
/invoke.?failed/i,
/tauri/i,
/command.?rejected/i,
];
const DATABASE_PATTERNS = [
/sqlite/i,
/database.?locked/i,
/database.?is.?locked/i,
/rusqlite/i,
/busy_timeout/i,
/SQLITE_BUSY/i,
/no.?such.?table/i,
/constraint.?failed/i,
];
const FILESYSTEM_PATTERNS = [
/ENOENT/,
/EACCES/,
/EPERM/,
/no.?such.?file/i,
/permission.?denied/i,
/not.?found/i,
/directory.?not.?empty/i,
];
function matchesAny(text: string, patterns: RegExp[]): boolean {
return patterns.some(p => p.test(text));
}
@ -112,6 +145,33 @@ export function classifyError(errorMessage: string): ClassifiedError {
};
}
if (matchesAny(errorMessage, IPC_PATTERNS)) {
return {
type: 'ipc',
message: 'Internal communication error. Try restarting.',
retryable: true,
retryDelaySec: 2,
};
}
if (matchesAny(errorMessage, DATABASE_PATTERNS)) {
return {
type: 'database',
message: 'Database error. Settings may not have saved.',
retryable: true,
retryDelaySec: 1,
};
}
if (matchesAny(errorMessage, FILESYSTEM_PATTERNS)) {
return {
type: 'filesystem',
message: 'File system error. Check permissions.',
retryable: false,
retryDelaySec: 0,
};
}
return {
type: 'unknown',
message: errorMessage,

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { extractErrorMessage } from './extract-error-message';
describe('extractErrorMessage', () => {
it('returns string errors as-is', () => {
expect(extractErrorMessage('simple error')).toBe('simple error');
});
it('extracts .message from Error objects', () => {
expect(extractErrorMessage(new Error('boom'))).toBe('boom');
});
it('extracts .message from TypeError', () => {
expect(extractErrorMessage(new TypeError('bad type'))).toBe('bad type');
});
it('handles Rust AppError with string detail', () => {
expect(extractErrorMessage({ kind: 'Database', detail: 'connection refused' }))
.toBe('Database: connection refused');
});
it('handles Rust AppError with object detail', () => {
expect(extractErrorMessage({ kind: 'Auth', detail: { reason: 'expired' } }))
.toBe('Auth: {"reason":"expired"}');
});
it('handles Rust AppError with kind only (no detail)', () => {
expect(extractErrorMessage({ kind: 'NotFound' })).toBe('NotFound');
});
it('handles objects with message property', () => {
expect(extractErrorMessage({ message: 'IPC error' })).toBe('IPC error');
});
it('prefers kind over message when both present', () => {
expect(extractErrorMessage({ kind: 'Timeout', detail: 'too slow', message: 'fail' }))
.toBe('Timeout: too slow');
});
it('handles null', () => {
expect(extractErrorMessage(null)).toBe('null');
});
it('handles undefined', () => {
expect(extractErrorMessage(undefined)).toBe('undefined');
});
it('handles numbers', () => {
expect(extractErrorMessage(42)).toBe('42');
});
it('handles empty string', () => {
expect(extractErrorMessage('')).toBe('');
});
it('handles empty object', () => {
expect(extractErrorMessage({})).toBe('[object Object]');
});
});

View file

@ -0,0 +1,26 @@
// Extract a human-readable message from any error shape
/**
* Normalize any caught error value into a readable string.
* Handles: string, Error, Tauri IPC errors, Rust AppError {kind, detail}, objects with .message.
*/
export function extractErrorMessage(err: unknown): string {
if (typeof err === 'string') return err;
if (err instanceof Error) return err.message;
if (err && typeof err === 'object') {
const obj = err as Record<string, unknown>;
// Rust AppError tagged enum: { kind: "Database", detail: { ... } }
if ('kind' in obj && typeof obj.kind === 'string') {
if ('detail' in obj && obj.detail) {
const detail = typeof obj.detail === 'string'
? obj.detail
: JSON.stringify(obj.detail);
return `${obj.kind}: ${detail}`;
}
return obj.kind;
}
// Generic object with message property
if ('message' in obj && typeof obj.message === 'string') return obj.message;
}
return String(err);
}

View file

@ -0,0 +1,42 @@
// Centralized error handling — two utilities for two contexts
//
// handleError: user-facing errors → classify, log, toast
// handleInfraError: infrastructure errors → log only, never toast
import { extractErrorMessage } from './extract-error-message';
import { classifyError, type ClassifiedError } from './error-classifier';
import { notify } from '../stores/notifications.svelte';
import { tel } from '../adapters/telemetry-bridge';
/** User-facing error handler. Logs to telemetry AND shows a toast. */
export function handleError(
err: unknown,
context: string,
userIntent?: string,
): ClassifiedError {
const msg = extractErrorMessage(err);
const classified = classifyError(msg);
tel.error(context, { error: msg, type: classified.type, retryable: classified.retryable });
const userMsg = userIntent
? `Couldn't ${userIntent}. ${classified.message}`
: classified.message;
notify('error', userMsg);
return classified;
}
/** Infrastructure-only error handler. Logs to telemetry, NEVER toasts.
* Use for: telemetry-bridge, notifications-bridge, heartbeats, fire-and-forget persistence. */
export function handleInfraError(err: unknown, context: string): void {
const msg = extractErrorMessage(err);
tel.error(`[infra] ${context}`, { error: msg });
}
/** Convenience: user-facing warning (toast + log, no classification). */
export function handleWarning(message: string, context: string): void {
tel.warn(context, { message });
notify('warning', message);
}