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 @@
+
+
+
+
+ {config.label}
+
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]}
+
+
+
+ {#if toast.type === 'error'}
+
+ {:else if toast.type === 'warning'}
+
+ {:else if toast.type === 'success'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
{typeLabels[toast.type]}
+
{toast.message}
+
+
+
+ {#if toast.dismissable}
+
+ {/if}
+
+ {/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()}
+