Merge pull request 'feat: error handling and connection status (#20)' (#40) from feature/issue-20-error-handling into main

This commit was merged in pull request #40.
This commit is contained in:
2026-03-12 13:28:34 +01:00
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}