Skip to content

Error Handling ​

Comprehensive error handling is crucial for building robust Tauri applications. This guide covers various error handling strategies and patterns when using Nuxt Tauri composables.

Understanding Error Types ​

Tauri API Errors ​

When using Tauri composables, you'll encounter different types of errors:

  1. API Unavailable: Running in browser environment
  2. Command Not Found: Rust command not registered
  3. Command Execution Errors: Errors from your Rust code
  4. Network/IPC Errors: Communication failures
  5. Serialization Errors: Data format issues

Basic Error Handling ​

Simple Error Display ​

vue
<template>
    <div class="error-example">
        <button @click="execute" :disabled="pending">
            {{ pending ? "Loading..." : "Execute Command" }}
        </button>

        <!-- Basic error display -->
        <div v-if="error" class="error-message">
            <h4>Error Occurred</h4>
            <p>{{ error.message }}</p>
            <button @click="execute" class="retry-btn">Try Again</button>
        </div>

        <div v-if="data" class="success-message">
            Operation completed successfully!
        </div>
    </div>
</template>

<script setup>
const { data, pending, error, execute } = useTauriInvoke(
    "potentially_failing_command"
);
</script>

<style scoped>
.error-message {
    background: #fee;
    border: 1px solid #fcc;
    border-radius: 4px;
    padding: 1rem;
    margin: 1rem 0;
    color: #c33;
}

.success-message {
    background: #efe;
    border: 1px solid #cfc;
    border-radius: 4px;
    padding: 1rem;
    margin: 1rem 0;
    color: #3c3;
}

.retry-btn {
    background: #dc3545;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    margin-top: 0.5rem;
}
</style>

Advanced Error Handling Patterns ​

Error Classification and User-Friendly Messages ​

vue
<template>
    <div class="advanced-error-handling">
        <button @click="execute" :disabled="pending">Load User Data</button>

        <div v-if="pending" class="loading">Loading user data...</div>

        <div v-if="processedError" class="error-container">
            <div class="error-icon">
                {{ getErrorIcon(processedError.type) }}
            </div>
            <div class="error-content">
                <h4>{{ processedError.title }}</h4>
                <p>{{ processedError.message }}</p>
                <div v-if="processedError.actions" class="error-actions">
                    <button
                        v-for="action in processedError.actions"
                        :key="action.label"
                        @click="action.handler"
                        :class="action.style"
                    >
                        {{ action.label }}
                    </button>
                </div>
            </div>
        </div>

        <div v-if="data" class="user-data">
            <h3>{{ data.name }}</h3>
            <p>{{ data.email }}</p>
        </div>
    </div>
</template>

<script setup>
interface ProcessedError {
  type: 'network' | 'permission' | 'validation' | 'system' | 'unknown'
  title: string
  message: string
  actions?: Array<{
    label: string
    handler: () => void
    style: string
  }>
}

const { data, pending, error, execute } = useTauriInvoke<User>('get_user_data')

const processedError = computed<ProcessedError | null>(() => {
  if (!error.value) return null

  const errorMessage = error.value.message.toLowerCase()

  // Network/connectivity errors
  if (errorMessage.includes('connection') || errorMessage.includes('timeout')) {
    return {
      type: 'network',
      title: 'Connection Problem',
      message: 'Unable to connect to the server. Please check your internet connection.',
      actions: [
        {
          label: 'Retry',
          handler: execute,
          style: 'btn-primary'
        },
        {
          label: 'Work Offline',
          handler: () => navigateTo('/offline'),
          style: 'btn-secondary'
        }
      ]
    }
  }

  // Permission errors
  if (errorMessage.includes('permission') || errorMessage.includes('unauthorized')) {
    return {
      type: 'permission',
      title: 'Access Denied',
      message: 'You don\'t have permission to access this data. Please contact your administrator.',
      actions: [
        {
          label: 'Login Again',
          handler: () => navigateTo('/login'),
          style: 'btn-primary'
        }
      ]
    }
  }

  // Validation errors
  if (errorMessage.includes('validation') || errorMessage.includes('invalid')) {
    return {
      type: 'validation',
      title: 'Invalid Data',
      message: 'The data provided is not valid. Please check your input and try again.',
      actions: [
        {
          label: 'Go Back',
          handler: () => window.history.back(),
          style: 'btn-secondary'
        }
      ]
    }
  }

  // Tauri API not available (browser environment)
  if (errorMessage.includes('tauri api not available')) {
    return {
      type: 'system',
      title: 'Desktop Features Unavailable',
      message: 'This feature is only available in the desktop application.',
      actions: [
        {
          label: 'Download Desktop App',
          handler: () => window.open('/download', '_blank'),
          style: 'btn-primary'
        },
        {
          label: 'Continue in Browser',
          handler: () => navigateTo('/web-version'),
          style: 'btn-secondary'
        }
      ]
    }
  }

  // Generic error fallback
  return {
    type: 'unknown',
    title: 'Something Went Wrong',
    message: 'An unexpected error occurred. Our team has been notified.',
    actions: [
      {
        label: 'Try Again',
        handler: execute,
        style: 'btn-primary'
      },
      {
        label: 'Report Issue',
        handler: () => navigateTo('/support'),
        style: 'btn-secondary'
      }
    ]
  }
})

const getErrorIcon = (type: string) => {
  const icons = {
    network: '🌐',
    permission: '🔒',
    validation: '⚠️',
    system: '💻',
    unknown: '❌'
  }
  return icons[type as keyof typeof icons] || '❌'
}
</script>

<style scoped>
.error-container {
    display: flex;
    gap: 1rem;
    background: #fef7f7;
    border: 1px solid #fecaca;
    border-radius: 8px;
    padding: 1.5rem;
    margin: 1rem 0;
}

.error-icon {
    font-size: 2rem;
    flex-shrink: 0;
}

.error-content {
    flex: 1;
}

.error-content h4 {
    margin: 0 0 0.5rem 0;
    color: #dc2626;
}

.error-content p {
    margin: 0 0 1rem 0;
    color: #7f1d1d;
}

.error-actions {
    display: flex;
    gap: 0.5rem;
}

.btn-primary {
    background: #dc2626;
    color: white;
    border: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
}

.btn-secondary {
    background: transparent;
    color: #dc2626;
    border: 1px solid #dc2626;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
}
</style>

Global Error Handler ​

typescript
// composables/useGlobalErrorHandler.ts
interface ErrorReport {
    error: Error;
    context: string;
    timestamp: number;
    userId?: string;
    metadata?: Record<string, any>;
}

export function useGlobalErrorHandler() {
    const errorReports = ref<ErrorReport[]>([]);

    const reportError = async (
        error: Error,
        context: string,
        metadata?: Record<string, any>
    ) => {
        const report: ErrorReport = {
            error,
            context,
            timestamp: Date.now(),
            userId: getCurrentUserId(),
            metadata,
        };

        // Store locally
        errorReports.value.push(report);

        // Send to backend for logging
        try {
            await useTauriInvoke("report_error", {
                message: error.message,
                stack: error.stack,
                context,
                timestamp: report.timestamp,
                userId: report.userId,
                metadata,
            }).execute();
        } catch (reportingError) {
            console.error("Failed to report error:", reportingError);
        }

        // Show user notification if appropriate
        if (shouldShowUserNotification(error, context)) {
            showErrorNotification(error, context);
        }
    };

    const shouldShowUserNotification = (
        error: Error,
        context: string
    ): boolean => {
        // Don't show notifications for background operations
        if (context.includes("background") || context.includes("sync")) {
            return false;
        }

        // Don't show for network errors if we're already showing connection status
        if (
            error.message.includes("connection") &&
            hasActiveConnectionWarning()
        ) {
            return false;
        }

        return true;
    };

    const showErrorNotification = (error: Error, context: string) => {
        // This would integrate with your notification system
        console.error(`Error in ${context}:`, error);
    };

    const clearErrorReports = () => {
        errorReports.value = [];
    };

    return {
        errorReports: readonly(errorReports),
        reportError,
        clearErrorReports,
    };
}

Retry Logic with Exponential Backoff ​

vue
<template>
    <div class="retry-example">
        <h3>Data Sync Status</h3>

        <div class="sync-status">
            <span class="status-indicator" :class="syncStatus">
                {{ getSyncStatusText() }}
            </span>

            <div v-if="isRetrying" class="retry-info">
                Retrying in {{ remainingTime }}s (Attempt
                {{ currentAttempt }}/{{ maxRetries }})
                <button @click="cancelRetry" class="cancel-btn">Cancel</button>
            </div>
        </div>

        <button @click="startSync" :disabled="pending || isRetrying">
            {{ pending ? "Syncing..." : "Start Sync" }}
        </button>

        <div v-if="error && !isRetrying" class="error-details">
            <h4>Sync Failed</h4>
            <p>{{ error.message }}</p>
            <button @click="startSync" class="retry-manual">Retry Now</button>
        </div>

        <div v-if="data" class="sync-success">
            Last sync: {{ formatTime(data.timestamp) }} ({{
                data.itemsSynced
            }}
            items)
        </div>
    </div>
</template>

<script setup>
interface SyncResult {
  timestamp: number
  itemsSynced: number
}

const maxRetries = 5
const baseDelay = 1000 // 1 second

const { data, pending, error, execute } = useTauriInvoke<SyncResult>('sync_data')

const currentAttempt = ref(0)
const isRetrying = ref(false)
const remainingTime = ref(0)
const retryTimeoutId = ref<NodeJS.Timeout>()
const countdownIntervalId = ref<NodeJS.Timeout>()

const syncStatus = computed(() => {
  if (pending.value) return 'syncing'
  if (isRetrying.value) return 'retrying'
  if (error.value) return 'error'
  if (data.value) return 'success'
  return 'idle'
})

const getSyncStatusText = () => {
  const statusTexts = {
    idle: 'Ready to sync',
    syncing: 'Syncing...',
    retrying: 'Will retry',
    error: 'Sync failed',
    success: 'Synced successfully'
  }
  return statusTexts[syncStatus.value]
}

const calculateDelay = (attempt: number): number => {
  // Exponential backoff with jitter
  const exponentialDelay = baseDelay * Math.pow(2, attempt)
  const jitter = Math.random() * 1000 // Add up to 1 second of jitter
  return Math.min(exponentialDelay + jitter, 30000) // Cap at 30 seconds
}

const startRetry = async () => {
  if (currentAttempt.value >= maxRetries) {
    console.error('Max retries exceeded')
    return
  }

  currentAttempt.value++
  isRetrying.value = true

  const delay = calculateDelay(currentAttempt.value - 1)
  remainingTime.value = Math.ceil(delay / 1000)

  // Start countdown
  countdownIntervalId.value = setInterval(() => {
    remainingTime.value--
    if (remainingTime.value <= 0) {
      clearInterval(countdownIntervalId.value)
    }
  }, 1000)

  // Schedule retry
  retryTimeoutId.value = setTimeout(async () => {
    isRetrying.value = false
    await execute()
  }, delay)
}

const cancelRetry = () => {
  if (retryTimeoutId.value) {
    clearTimeout(retryTimeoutId.value)
  }
  if (countdownIntervalId.value) {
    clearInterval(countdownIntervalId.value)
  }
  isRetrying.value = false
  currentAttempt.value = 0
}

const startSync = async () => {
  currentAttempt.value = 0
  cancelRetry()
  await execute()
}

// Watch for errors and start retry logic
watch(error, (newError) => {
  if (newError && !isRetrying.value && currentAttempt.value < maxRetries) {
    // Check if error is retryable
    const isRetryable = !newError.message.includes('permission') &&
                       !newError.message.includes('unauthorized')

    if (isRetryable) {
      startRetry()
    }
  }
})

// Watch for successful sync to reset retry counter
watch(data, (newData) => {
  if (newData) {
    currentAttempt.value = 0
    cancelRetry()
  }
})

const formatTime = (timestamp: number) => {
  return new Date(timestamp).toLocaleString()
}

// Cleanup on unmount
onUnmounted(() => {
  cancelRetry()
})
</script>

<style scoped>
.sync-status {
    margin-bottom: 1rem;
}

.status-indicator {
    padding: 0.5rem 1rem;
    border-radius: 20px;
    font-weight: bold;
    text-transform: uppercase;
    font-size: 0.875rem;
}

.status-indicator.idle {
    background: #f3f4f6;
    color: #6b7280;
}

.status-indicator.syncing {
    background: #dbeafe;
    color: #1d4ed8;
}

.status-indicator.retrying {
    background: #fef3c7;
    color: #d97706;
}

.status-indicator.error {
    background: #fee2e2;
    color: #dc2626;
}

.status-indicator.success {
    background: #dcfce7;
    color: #16a34a;
}

.retry-info {
    margin-top: 0.5rem;
    padding: 0.5rem;
    background: #fffbeb;
    border-radius: 4px;
    font-size: 0.875rem;
}

.cancel-btn {
    margin-left: 0.5rem;
    padding: 0.25rem 0.5rem;
    background: #f59e0b;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.75rem;
}

.error-details {
    background: #fef2f2;
    border: 1px solid #fecaca;
    border-radius: 4px;
    padding: 1rem;
    margin: 1rem 0;
}

.retry-manual {
    margin-top: 0.5rem;
    padding: 0.5rem 1rem;
    background: #dc2626;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
}

.sync-success {
    background: #f0fdf4;
    border: 1px solid #bbf7d0;
    border-radius: 4px;
    padding: 1rem;
    margin: 1rem 0;
    color: #166534;
}
</style>

Error Boundaries and Recovery ​

Error Boundary Component ​

vue
<!-- components/ErrorBoundary.vue -->
<template>
    <div v-if="hasError" class="error-boundary">
        <div class="error-boundary-content">
            <h2>{{ errorTitle }}</h2>
            <p>{{ errorMessage }}</p>

            <details v-if="showDetails" class="error-details">
                <summary>Technical Details</summary>
                <pre>{{ errorStack }}</pre>
            </details>

            <div class="error-actions">
                <button @click="retry" class="btn-primary">Try Again</button>
                <button @click="goHome" class="btn-secondary">Go Home</button>
                <button @click="reportBug" class="btn-secondary">
                    Report Bug
                </button>
            </div>
        </div>
    </div>

    <div v-else>
        <slot />
    </div>
</template>

<script setup>
interface Props {
  fallbackTitle?: string
  fallbackMessage?: string
  showDetails?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  fallbackTitle: 'Something went wrong',
  fallbackMessage: 'An unexpected error occurred. Please try again.',
  showDetails: true
})

const hasError = ref(false)
const currentError = ref<Error | null>(null)

const errorTitle = computed(() => props.fallbackTitle)
const errorMessage = computed(() => props.fallbackMessage)
const errorStack = computed(() => currentError.value?.stack || 'No stack trace available')

const handleError = (error: Error) => {
  console.error('Error boundary caught:', error)
  currentError.value = error
  hasError.value = true

  // Report error to monitoring service
  useGlobalErrorHandler().reportError(error, 'error-boundary')
}

const retry = () => {
  hasError.value = false
  currentError.value = null
  // The component will re-render and try again
}

const goHome = () => {
  navigateTo('/')
}

const reportBug = () => {
  // Open bug report form with error details
  const bugReport = {
    error: currentError.value?.message,
    stack: currentError.value?.stack,
    userAgent: navigator.userAgent,
    timestamp: new Date().toISOString()
  }

  navigateTo('/support/bug-report', {
    query: {
      data: encodeURIComponent(JSON.stringify(bugReport))
    }
  })
}

// Expose error handler for parent components
defineExpose({
  handleError
})

// Global error handler
onErrorCaptured((error) => {
  handleError(error)
  return false // Prevent error from propagating
})
</script>

<style scoped>
.error-boundary {
    min-height: 400px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #fef2f2;
    border: 1px solid #fecaca;
    border-radius: 8px;
    margin: 2rem 0;
}

.error-boundary-content {
    text-align: center;
    max-width: 500px;
    padding: 2rem;
}

.error-boundary-content h2 {
    color: #dc2626;
    margin-bottom: 1rem;
}

.error-boundary-content p {
    color: #7f1d1d;
    margin-bottom: 2rem;
}

.error-details {
    text-align: left;
    margin: 1rem 0;
    background: white;
    border-radius: 4px;
    padding: 1rem;
}

.error-details pre {
    font-size: 0.875rem;
    overflow-x: auto;
    white-space: pre-wrap;
    word-break: break-word;
}

.error-actions {
    display: flex;
    gap: 0.5rem;
    justify-content: center;
    flex-wrap: wrap;
}

.btn-primary {
    background: #dc2626;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
}

.btn-secondary {
    background: transparent;
    color: #dc2626;
    border: 1px solid #dc2626;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
}
</style>

Circuit Breaker Pattern ​

typescript
// composables/useCircuitBreaker.ts
interface CircuitBreakerState {
    state: "CLOSED" | "OPEN" | "HALF_OPEN";
    failureCount: number;
    lastFailureTime: number;
    nextAttemptTime: number;
}

export function useCircuitBreaker(
    command: string,
    args?: Record<string, unknown>,
    options: {
        failureThreshold?: number;
        timeout?: number;
        resetTimeout?: number;
    } = {}
) {
    const {
        failureThreshold = 5,
        timeout = 30000,
        resetTimeout = 60000,
    } = options;

    const circuitState = ref<CircuitBreakerState>({
        state: "CLOSED",
        failureCount: 0,
        lastFailureTime: 0,
        nextAttemptTime: 0,
    });

    const {
        data,
        pending,
        error: rawError,
        execute: rawExecute,
    } = useTauriInvoke(command, args);

    const error = ref<Error | null>(null);

    const canExecute = computed(() => {
        const now = Date.now();

        switch (circuitState.value.state) {
            case "CLOSED":
                return true;

            case "OPEN":
                if (now >= circuitState.value.nextAttemptTime) {
                    circuitState.value.state = "HALF_OPEN";
                    return true;
                }
                return false;

            case "HALF_OPEN":
                return true;

            default:
                return false;
        }
    });

    const execute = async () => {
        if (!canExecute.value) {
            error.value = new Error(
                "Circuit breaker is OPEN. Service is temporarily unavailable."
            );
            return;
        }

        try {
            await rawExecute();

            // Success - reset circuit breaker if it was open or half-open
            if (circuitState.value.state !== "CLOSED") {
                circuitState.value = {
                    state: "CLOSED",
                    failureCount: 0,
                    lastFailureTime: 0,
                    nextAttemptTime: 0,
                };
            }

            error.value = null;
        } catch (err) {
            circuitState.value.failureCount++;
            circuitState.value.lastFailureTime = Date.now();

            // Open circuit if failure threshold exceeded
            if (circuitState.value.failureCount >= failureThreshold) {
                circuitState.value.state = "OPEN";
                circuitState.value.nextAttemptTime = Date.now() + resetTimeout;
            }

            error.value = err as Error;
        }
    };

    return {
        data,
        pending,
        error: computed(() => error.value || rawError.value),
        execute,
        circuitState: readonly(circuitState),
        canExecute,
    };
}

Testing Error Scenarios ​

typescript
// tests/errorHandling.test.ts
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { mockUseTauriInvoke } from "./mocks/tauriMocks";

describe("Error Handling", () => {
    it("should display user-friendly error messages", async () => {
        const mockError = new Error("Connection timeout");
        const { simulateError } = mockUseTauriInvoke(null, mockError);

        const wrapper = mount(YourComponent);

        simulateError();
        await wrapper.vm.$nextTick();

        expect(wrapper.find(".error-message").text()).toContain(
            "Connection Problem"
        );
        expect(wrapper.find(".retry-btn").exists()).toBe(true);
    });

    it("should retry failed operations with exponential backoff", async () => {
        const mockExecute = vi
            .fn()
            .mockRejectedValueOnce(new Error("Temporary failure"))
            .mockRejectedValueOnce(new Error("Still failing"))
            .mockResolvedValueOnce({ success: true });

        // Test retry logic
        // ... implementation
    });

    it("should open circuit breaker after threshold failures", async () => {
        const circuitBreaker = useCircuitBreaker(
            "failing_command",
            {},
            {
                failureThreshold: 3,
            }
        );

        // Simulate failures
        for (let i = 0; i < 3; i++) {
            await circuitBreaker.execute();
        }

        expect(circuitBreaker.circuitState.value.state).toBe("OPEN");
        expect(circuitBreaker.canExecute.value).toBe(false);
    });
});

This comprehensive error handling guide provides robust patterns for dealing with various error scenarios in Tauri applications, ensuring a better user experience and easier debugging.

Next Steps ​