diff --git a/implementation-plans/_index.md b/implementation-plans/_index.md index 8c648e0..f2ea7f0 100644 --- a/implementation-plans/_index.md +++ b/implementation-plans/_index.md @@ -21,3 +21,4 @@ | #17 | Audit/activity log view | COMPLETED | [issue-017.md](issue-017.md) | | #18 | Dark/light theme toggle | COMPLETED | [issue-018.md](issue-018.md) | | #19 | Responsive layout and mobile support | COMPLETED | [issue-019.md](issue-019.md) | +| #20 | Error handling and connection status | COMPLETED | [issue-020.md](issue-020.md) | diff --git a/implementation-plans/issue-020.md b/implementation-plans/issue-020.md new file mode 100644 index 0000000..fad6367 --- /dev/null +++ b/implementation-plans/issue-020.md @@ -0,0 +1,44 @@ +# Issue #20 — Error handling and connection status + +**Status: COMPLETED** + +## Overview +Global error boundary, toast notifications for gRPC errors, connection status indicator, and retry logic with exponential backoff. + +## Implemented Components + +### Toast System +- **`src/lib/stores/toast.svelte.ts`** — Svelte 5 runes-based toast store with `addToast`, `removeToast`, `clear`. Auto-dismiss with configurable durations (5s for success/info, 10s for errors, 8s for warnings). +- **`src/lib/components/ToastContainer.svelte`** — Fixed bottom-right toast stack. Color-coded by type (red/amber/green/blue) with icons, dismiss buttons, and full dark mode support. + +### Connection Status +- **`src/lib/stores/connection.svelte.ts`** — Tracks gRPC connection state (`connected` / `reconnecting` / `disconnected`) based on consecutive failures. +- **`src/lib/components/ConnectionStatus.svelte`** — Header indicator with colored dot (green/amber-pulse/red) and label. Dark mode support. + +### Retry Logic +- **`src/lib/services/orchestrator.ts`** — Added: + - Exponential backoff retry (up to 3 retries) for transient gRPC codes (`UNAVAILABLE`, `DEADLINE_EXCEEDED`, `ABORTED`, `INTERNAL`). + - `friendlyMessage()` mapper from gRPC codes to user-friendly strings. + - Connection store integration (reports success/failure). + - Toast notifications on errors. + +### Error Boundary +- **`src/routes/+error.svelte`** — Global SvelteKit error page with status code, friendly message, and "Go back" / "Try again" / "Home" buttons. Full dark mode support. + +### Integration +- **`src/routes/+layout.svelte`** — Added `ToastContainer` to global layout. +- **`src/routes/chat/+page.svelte`** — Added `ConnectionStatus` in header, "Retry" button on failed requests, toast notifications on errors, user-friendly error messages. + +## Files Changed +| File | Change | +|------|--------| +| `src/lib/stores/toast.svelte.ts` | New | +| `src/lib/stores/connection.svelte.ts` | New | +| `src/lib/components/ToastContainer.svelte` | New | +| `src/lib/components/ConnectionStatus.svelte` | New | +| `src/routes/+error.svelte` | New | +| `src/lib/services/orchestrator.ts` | Modified — retry, friendly messages, store integration | +| `src/routes/+layout.svelte` | Modified — added ToastContainer | +| `src/routes/chat/+page.svelte` | Modified — ConnectionStatus, Retry button, toast integration | +| `implementation-plans/issue-020.md` | New | +| `implementation-plans/_index.md` | Modified | diff --git a/src/lib/components/ConnectionStatus.svelte b/src/lib/components/ConnectionStatus.svelte new file mode 100644 index 0000000..0b15b88 --- /dev/null +++ b/src/lib/components/ConnectionStatus.svelte @@ -0,0 +1,28 @@ + + +
+ + +
diff --git a/src/lib/components/ToastContainer.svelte b/src/lib/components/ToastContainer.svelte new file mode 100644 index 0000000..2d27add --- /dev/null +++ b/src/lib/components/ToastContainer.svelte @@ -0,0 +1,86 @@ + + +{#if toastStore.toasts.length > 0} +
+ {#each toastStore.toasts as toast (toast.id)} + {@const style = typeStyles[toast.type]} + + {/each} +
+{/if} diff --git a/src/lib/services/orchestrator.ts b/src/lib/services/orchestrator.ts index 1fe6d52..4f1fbe9 100644 --- a/src/lib/services/orchestrator.ts +++ b/src/lib/services/orchestrator.ts @@ -7,6 +7,8 @@ import type { } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; import { create } from '@bufbuild/protobuf'; import { ProcessRequestRequestSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb'; +import { connectionStore } from '$lib/stores/connection.svelte'; +import { toastStore } from '$lib/stores/toast.svelte'; /** * Application-level error wrapping gRPC status codes. @@ -22,6 +24,45 @@ export class OrchestratorError extends Error { } } +/** + * Map gRPC status codes to user-friendly messages. + */ +const GRPC_USER_MESSAGES: Record = { + unavailable: 'The server is currently unavailable. Please try again later.', + deadline_exceeded: 'The request timed out. Please try again.', + cancelled: 'The request was cancelled.', + not_found: 'The requested resource was not found.', + already_exists: 'The resource already exists.', + permission_denied: 'You do not have permission to perform this action.', + resource_exhausted: 'Rate limit reached. Please wait a moment and try again.', + failed_precondition: 'The operation cannot be performed in the current state.', + aborted: 'The operation was aborted. Please try again.', + unimplemented: 'This feature is not yet available.', + internal: 'An internal server error occurred. Please try again.', + unauthenticated: 'Authentication required. Please log in.', + data_loss: 'Data loss detected. Please contact support.', + unknown: 'An unexpected error occurred. Please try again.' +}; + +/** + * Codes considered transient / retriable. + */ +const TRANSIENT_CODES = new Set(['unavailable', 'deadline_exceeded', 'aborted', 'internal']); + +/** + * Return a user-friendly message for a gRPC error code. + */ +export function friendlyMessage(code: string): string { + return GRPC_USER_MESSAGES[code.toLowerCase()] ?? GRPC_USER_MESSAGES['unknown']; +} + +/** + * Whether the given error code is transient and should be retried. + */ +function isTransient(code: string): boolean { + return TRANSIENT_CODES.has(code.toLowerCase()); +} + const DEFAULT_ENDPOINT = '/'; let transport: ReturnType | null = null; @@ -49,9 +90,56 @@ function getClient(endpoint?: string) { return createClient(OrchestratorService, getTransport(endpoint)); } +/** + * Sleep for a given number of milliseconds. + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Calculate exponential backoff delay with jitter. + * Base delay doubles each attempt: 1s, 2s, 4s (capped at 8s). + */ +function backoffDelay(attempt: number): number { + const base = Math.min(1000 * Math.pow(2, attempt), 8000); + const jitter = Math.random() * base * 0.25; + return base + jitter; +} + +const MAX_RETRIES = 3; + +/** + * Extract gRPC error code from an error, normalising to lowercase string. + */ +function extractCode(err: unknown): string { + if (err instanceof Error && 'code' in err) { + const raw = (err as { code: unknown }).code; + if (typeof raw === 'string') return raw.toLowerCase(); + if (typeof raw === 'number') return String(raw); + } + return 'unknown'; +} + +/** + * Wrap an error into an OrchestratorError with a friendly message. + */ +function toOrchestratorError(err: unknown): OrchestratorError { + if (err instanceof OrchestratorError) return err; + if (err instanceof Error) { + const code = extractCode(err); + return new OrchestratorError(friendlyMessage(code), code, err.message); + } + return new OrchestratorError(friendlyMessage('unknown'), 'unknown'); +} + /** * Send a request to the orchestrator and yield streaming responses. * + * Includes automatic retry with exponential backoff for transient failures. + * Updates the connection status store on success or failure and fires + * toast notifications on errors. + * * Returns an async iterator of `ProcessRequestResponse` messages, * each containing the current orchestration state, status message, * and optionally intermediate or final results. @@ -62,28 +150,48 @@ export async function* processRequest( sessionConfig?: SessionConfig, endpoint?: string ): AsyncGenerator { - const client = getClient(endpoint); - const request = create(ProcessRequestRequestSchema, { sessionId, userMessage, sessionConfig }); - try { - for await (const response of client.processRequest(request)) { - yield response; + let lastError: OrchestratorError | null = null; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + connectionStore.setReconnecting(); + const delay = backoffDelay(attempt - 1); + await sleep(delay); } - } catch (err: unknown) { - if (err instanceof Error) { - // ConnectError has a `code` property - const code = 'code' in err ? (err as { code: unknown }).code : undefined; - throw new OrchestratorError( - err.message, - typeof code === 'string' ? code : 'unknown', - err.message - ); + + try { + const client = getClient(endpoint); + for await (const response of client.processRequest(request)) { + connectionStore.reportSuccess(); + yield response; + } + // Completed successfully — no retry needed + return; + } catch (err: unknown) { + lastError = toOrchestratorError(err); + const code = lastError.code; + + if (isTransient(code) && attempt < MAX_RETRIES) { + // Will retry — continue loop + connectionStore.reportFailure(); + continue; + } + + // Non-transient or exhausted retries + connectionStore.reportFailure(); + toastStore.addToast({ message: lastError.message, type: 'error' }); + throw lastError; } - throw new OrchestratorError('Unknown error', 'unknown'); + } + + // Should not reach here, but guard against it + if (lastError) { + throw lastError; } } diff --git a/src/lib/stores/connection.svelte.ts b/src/lib/stores/connection.svelte.ts new file mode 100644 index 0000000..69484ae --- /dev/null +++ b/src/lib/stores/connection.svelte.ts @@ -0,0 +1,38 @@ +export type ConnectionStatus = 'connected' | 'reconnecting' | 'disconnected'; + +function createConnectionStore() { + let status: ConnectionStatus = $state('connected'); + let consecutiveFailures = $state(0); + + function reportSuccess() { + status = 'connected'; + consecutiveFailures = 0; + } + + function reportFailure() { + consecutiveFailures += 1; + if (consecutiveFailures >= 3) { + status = 'disconnected'; + } else { + status = 'reconnecting'; + } + } + + function setReconnecting() { + status = 'reconnecting'; + } + + return { + get status() { + return status; + }, + get consecutiveFailures() { + return consecutiveFailures; + }, + reportSuccess, + reportFailure, + setReconnecting + }; +} + +export const connectionStore = createConnectionStore(); diff --git a/src/lib/stores/toast.svelte.ts b/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..d3a21dd --- /dev/null +++ b/src/lib/stores/toast.svelte.ts @@ -0,0 +1,82 @@ +export type ToastType = 'error' | 'warning' | 'success' | 'info'; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration: number; + dismissable: boolean; +} + +const DEFAULT_DURATIONS: Record = { + success: 5000, + info: 5000, + warning: 8000, + error: 10000 +}; + +function createToastStore() { + let toasts: Toast[] = $state([]); + // Internal bookkeeping only — not reactive state, so Map is intentional. + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const timers = new Map>(); + + function addToast( + options: Omit & { + id?: string; + duration?: number; + dismissable?: boolean; + } + ): string { + const id = options.id ?? crypto.randomUUID(); + const duration = options.duration ?? DEFAULT_DURATIONS[options.type]; + const dismissable = options.dismissable ?? true; + + const toast: Toast = { + id, + message: options.message, + type: options.type, + duration, + dismissable + }; + + toasts = [...toasts, toast]; + + if (duration > 0) { + const timer = setTimeout(() => { + removeToast(id); + }, duration); + timers.set(id, timer); + } + + return id; + } + + function removeToast(id: string) { + const timer = timers.get(id); + if (timer) { + clearTimeout(timer); + timers.delete(id); + } + toasts = toasts.filter((t) => t.id !== id); + } + + function clear() { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + toasts = []; + } + + return { + get toasts() { + return toasts; + }, + addToast, + removeToast, + clear + }; +} + +export const toastStore = createToastStore(); diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..8fb5006 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,54 @@ + + +
+
+ +
+ + + +
+ + +

{$page.status}

+ + +

+ Something went wrong +

+

+ {$page.error?.message ?? 'An unexpected error occurred. Please try again.'} +

+ + +
+ + + + + Home + + +
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index dd478ee..68a066f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import favicon from '$lib/assets/favicon.svg'; import '../app.css'; import { themeStore } from '$lib/stores/theme.svelte'; + import ToastContainer from '$lib/components/ToastContainer.svelte'; let { children } = $props(); @@ -15,6 +16,7 @@ {@render children()} +