diff --git a/src/lib/adapters/notifications-bridge.ts b/src/lib/adapters/notifications-bridge.ts index 8b5609e..e1eccf1 100644 --- a/src/lib/adapters/notifications-bridge.ts +++ b/src/lib/adapters/notifications-bridge.ts @@ -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); }); } diff --git a/src/lib/adapters/telemetry-bridge.ts b/src/lib/adapters/telemetry-bridge.ts index 394c596..6e7d8c9 100644 --- a/src/lib/adapters/telemetry-bridge.ts +++ b/src/lib/adapters/telemetry-bridge.ts @@ -11,8 +11,11 @@ export function telemetryLog( message: string, context?: Record, ): 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); }); } diff --git a/src/lib/stores/notifications.svelte.ts b/src/lib/stores/notifications.svelte.ts index 8206890..ef73d6e 100644 --- a/src/lib/stores/notifications.svelte.ts +++ b/src/lib/stores/notifications.svelte.ts @@ -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(); + +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() }); diff --git a/src/lib/utils/error-classifier.ts b/src/lib/utils/error-classifier.ts index f8cc48f..dc761ed 100644 --- a/src/lib/utils/error-classifier.ts +++ b/src/lib/utils/error-classifier.ts @@ -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, diff --git a/src/lib/utils/extract-error-message.test.ts b/src/lib/utils/extract-error-message.test.ts new file mode 100644 index 0000000..b5a997a --- /dev/null +++ b/src/lib/utils/extract-error-message.test.ts @@ -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]'); + }); +}); diff --git a/src/lib/utils/extract-error-message.ts b/src/lib/utils/extract-error-message.ts new file mode 100644 index 0000000..4614eb6 --- /dev/null +++ b/src/lib/utils/extract-error-message.ts @@ -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; + // 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); +} diff --git a/src/lib/utils/handle-error.ts b/src/lib/utils/handle-error.ts new file mode 100644 index 0000000..b3cde89 --- /dev/null +++ b/src/lib/utils/handle-error.ts @@ -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); +}