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:
parent
bfc01192d2
commit
dcdb741403
7 changed files with 215 additions and 4 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
59
src/lib/utils/extract-error-message.test.ts
Normal file
59
src/lib/utils/extract-error-message.test.ts
Normal 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]');
|
||||
});
|
||||
});
|
||||
26
src/lib/utils/extract-error-message.ts
Normal file
26
src/lib/utils/extract-error-message.ts
Normal 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);
|
||||
}
|
||||
42
src/lib/utils/handle-error.ts
Normal file
42
src/lib/utils/handle-error.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue