From 14c83832f56a2a04fddd9affa8299221eda11f47 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Thu, 12 Mar 2026 13:27:40 +0100 Subject: [PATCH] feat: add error handling, toast notifications, connection status, and retry logic (#20) Add global error boundary, toast notification system for gRPC errors, connection status indicator in chat header, and automatic retry with exponential backoff for transient failures. Map gRPC status codes to user-friendly messages and add a Retry button on failed requests. Co-Authored-By: Claude Opus 4.6 --- implementation-plans/_index.md | 1 + implementation-plans/issue-020.md | 44 +++++++ src/lib/components/ConnectionStatus.svelte | 28 +++++ src/lib/components/ToastContainer.svelte | 86 +++++++++++++ src/lib/services/orchestrator.ts | 138 ++++++++++++++++++--- src/lib/stores/connection.svelte.ts | 38 ++++++ src/lib/stores/toast.svelte.ts | 82 ++++++++++++ src/routes/+error.svelte | 54 ++++++++ src/routes/+layout.svelte | 2 + src/routes/chat/+page.svelte | 43 +++++-- 10 files changed, 493 insertions(+), 23 deletions(-) create mode 100644 implementation-plans/issue-020.md create mode 100644 src/lib/components/ConnectionStatus.svelte create mode 100644 src/lib/components/ToastContainer.svelte create mode 100644 src/lib/stores/connection.svelte.ts create mode 100644 src/lib/stores/toast.svelte.ts create mode 100644 src/routes/+error.svelte 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()} +