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 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-12 13:27:40 +01:00
parent 284f84bd39
commit 14c83832f5
10 changed files with 493 additions and 23 deletions

View File

@@ -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) |

View File

@@ -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 |

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { connectionStore } from '$lib/stores/connection.svelte';
const statusConfig = {
connected: {
dot: 'bg-green-500',
label: 'Connected',
textColor: 'text-green-700 dark:text-green-400'
},
reconnecting: {
dot: 'bg-amber-500 animate-pulse',
label: 'Reconnecting',
textColor: 'text-amber-700 dark:text-amber-400'
},
disconnected: {
dot: 'bg-red-500',
label: 'Disconnected',
textColor: 'text-red-700 dark:text-red-400'
}
};
const config = $derived(statusConfig[connectionStore.status]);
</script>
<div class="flex items-center gap-1.5" title="Server: {config.label}">
<span class="h-2 w-2 rounded-full {config.dot}"></span>
<span class="hidden text-xs {config.textColor} sm:inline">{config.label}</span>
</div>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { toastStore, type ToastType } from '$lib/stores/toast.svelte';
const typeStyles: Record<ToastType, { bg: string; border: string; icon: string }> = {
error: {
bg: 'bg-red-50 dark:bg-red-900/40',
border: 'border-red-200 dark:border-red-800',
icon: 'text-red-500 dark:text-red-400'
},
warning: {
bg: 'bg-amber-50 dark:bg-amber-900/40',
border: 'border-amber-200 dark:border-amber-800',
icon: 'text-amber-500 dark:text-amber-400'
},
success: {
bg: 'bg-green-50 dark:bg-green-900/40',
border: 'border-green-200 dark:border-green-800',
icon: 'text-green-500 dark:text-green-400'
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/40',
border: 'border-blue-200 dark:border-blue-800',
icon: 'text-blue-500 dark:text-blue-400'
}
};
const typeLabels: Record<ToastType, string> = {
error: 'Error',
warning: 'Warning',
success: 'Success',
info: 'Info'
};
</script>
{#if toastStore.toasts.length > 0}
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{#each toastStore.toasts as toast (toast.id)}
{@const style = typeStyles[toast.type]}
<div
class="pointer-events-auto flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg {style.bg} {style.border}"
role="alert"
>
<!-- Icon -->
<div class="mt-0.5 shrink-0 {style.icon}">
{#if toast.type === 'error'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
{:else if toast.type === 'warning'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
{:else if toast.type === 'success'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{:else}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
{/if}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-900 dark:text-gray-100">{typeLabels[toast.type]}</p>
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{toast.message}</p>
</div>
<!-- Dismiss button -->
{#if toast.dismissable}
<button
type="button"
onclick={() => toastStore.removeToast(toast.id)}
class="shrink-0 rounded-md p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
aria-label="Dismiss"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}

View File

@@ -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<string, string> = {
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<typeof createGrpcWebTransport> | 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<void> {
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<ProcessRequestResponse> {
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;
}
}

View File

@@ -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();

View File

@@ -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<ToastType, number> = {
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<string, ReturnType<typeof setTimeout>>();
function addToast(
options: Omit<Toast, 'id' | 'duration' | 'dismissable'> & {
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();

54
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolveRoute } from '$app/paths';
const homeHref = resolveRoute('/');
</script>
<div class="flex min-h-screen items-center justify-center bg-white px-4 dark:bg-gray-900">
<div class="w-full max-w-md text-center">
<!-- Error icon -->
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<svg class="h-8 w-8 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<!-- Status code -->
<p class="text-5xl font-bold text-gray-900 dark:text-gray-100">{$page.status}</p>
<!-- Message -->
<h1 class="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Something went wrong
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$page.error?.message ?? 'An unexpected error occurred. Please try again.'}
</p>
<!-- Actions -->
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
type="button"
onclick={() => history.back()}
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Go back
</button>
<button
type="button"
onclick={() => location.reload()}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Try again
</button>
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a
href={homeHref}
class="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Home
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
</div>
</div>

View File

@@ -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 @@
</svelte:head>
{@render children()}
<ToastContainer />
<style>
:global(.theme-transition),

View File

@@ -16,15 +16,18 @@
import SessionSidebar from '$lib/components/SessionSidebar.svelte';
import ConfigSidebar from '$lib/components/ConfigSidebar.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { processRequest, OrchestratorError } from '$lib/services/orchestrator';
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
import { processRequest, OrchestratorError, friendlyMessage } from '$lib/services/orchestrator';
import { OrchestrationState } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
import { sessionStore } from '$lib/stores/sessions.svelte';
import { memoryStore } from '$lib/stores/memory.svelte';
import { auditStore } from '$lib/stores/audit.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
let messages: ChatMessage[] = $state([]);
let isStreaming = $state(false);
let error: string | null = $state(null);
let lastFailedContent: string | null = $state(null);
let orchestrationState: OrchestrationState = $state(OrchestrationState.UNSPECIFIED);
let intermediateResult: string = $state('');
let finalResult: SubagentResult | null = $state(null);
@@ -79,6 +82,7 @@
async function handleSend(content: string) {
error = null;
lastFailedContent = null;
orchestrationState = OrchestrationState.UNSPECIFIED;
intermediateResult = '';
finalResult = null;
@@ -143,25 +147,37 @@
};
}
} catch (err) {
const msg =
const friendlyMsg =
err instanceof OrchestratorError
? `Error (${err.code}): ${err.message}`
? friendlyMessage(err.code)
: 'An unexpected error occurred';
error = msg;
error = friendlyMsg;
lastFailedContent = content;
toastStore.addToast({ message: friendlyMsg, type: 'error' });
auditStore.addEvent(sessionId, {
eventType: 'error',
details: msg
details: friendlyMsg
});
const idx = messages.length - 1;
messages[idx] = {
...messages[idx],
content: `\u26A0 ${msg}`
content: `\u26A0 ${friendlyMsg}`
};
} finally {
isStreaming = false;
sessionStore.updateMessages(sessionId, messages);
}
}
function handleRetry() {
if (!lastFailedContent) return;
const content = lastFailedContent;
// Remove the failed assistant message before retrying
if (messages.length >= 2) {
messages = messages.slice(0, -2);
}
handleSend(content);
}
</script>
<div class="flex h-screen overflow-hidden bg-white dark:bg-gray-900">
@@ -194,6 +210,7 @@
</svg>
</button>
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Chat</h1>
<ConnectionStatus />
</div>
<div class="flex items-center gap-1 md:gap-2">
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
@@ -263,8 +280,18 @@
{/if}
{#if error}
<div class="mx-4 mb-2 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-2 text-sm text-red-600 dark:text-red-400">
{error}
<div class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-2 text-sm text-red-600 dark:text-red-400">
<span>{error}</span>
{#if lastFailedContent}
<button
type="button"
onclick={handleRetry}
disabled={isStreaming}
class="shrink-0 rounded-md bg-red-100 px-3 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-800/50 dark:text-red-300 dark:hover:bg-red-800 disabled:opacity-50"
>
Retry
</button>
{/if}
</div>
{/if}