/ Docs

Error Handling

The Volcano SDK returns error objects instead of throwing, so you can inspect and handle failures consistently across every operation.

The Volcano SDK uses a consistent error handling pattern across all operations. Rather than throwing exceptions, methods return error objects that you can inspect and handle appropriately.

The Error Pattern

All SDK methods return an object with an error property:

const { data, error } = await volcano.from('posts').select('*');

if (error) {
  console.error('Something went wrong:', error.message);
  return;
}

// data is safe to use
console.log(data);

This pattern has several advantages:

  • Explicit error handling - You must acknowledge the error property
  • No try/catch needed - Errors don't interrupt execution flow
  • Consistent API - Same pattern for auth, database, storage, and functions
  • Type safety - TypeScript knows the data type when error is checked

Authentication Errors

Sign Up

const { confirmationRequired, message, error } = await volcano.auth.signUp({
  email: 'user@example.com',
  password: 'weak',
});

if (error) {
  switch (true) {
    case error.message.includes('already exists'):
      showError('An account with this email already exists. Try signing in.');
      break;
    case error.message.includes('weak password'):
      showError('Please choose a stronger password with at least 8 characters.');
      break;
    case error.message.includes('invalid email'):
      showError('Please enter a valid email address.');
      break;
    default:
      showError('Sign up failed. Please try again.');
      console.error('Sign up error:', error);
  }
  return;
}

// Success — sign up is session-less; sign in next to authenticate.
console.log(message ?? 'Account created. You can now sign in.');

Sign In

const { user, session, error } = await volcano.auth.signIn({
  email: 'user@example.com',
  password: 'password123',
});

if (error) {
  switch (true) {
    case error.message.includes('Invalid credentials'):
      showError('Incorrect email or password.');
      break;
    case error.message.includes('email not confirmed'):
      showError('Please confirm your email before signing in.');
      break;
    case error.message.includes('too many attempts'):
      showError('Too many failed attempts. Please try again later.');
      break;
    default:
      showError('Sign in failed. Please try again.');
      console.error('Sign in error:', error);
  }
  return;
}

Session Errors

const { user, error } = await volcano.auth.getUser();

if (error) {
  if (error.message.includes('No active session')) {
    // User is not logged in
    redirectToLogin();
    return;
  }

  if (error.message.includes('Session expired')) {
    // Session needs refresh (SDK usually handles this automatically)
    const { session, error: refreshError } = await volcano.auth.refreshSession();
    if (refreshError) {
      redirectToLogin();
      return;
    }
    // Retry the original request
  }
}

Concurrent Session Changes

The SDK coordinates concurrent refresh attempts for the same session. If a sign-in, sign-out, or other auth operation replaces that session before a refresh can commit, the SDK keeps the newer session and does not replay the original request under it.

AuthRefreshDiscardedError identifies a successful refresh result that the SDK discarded, or a request that detected the replacement before starting refresh. If the old refresh request itself fails, its original refresh or request error may be returned instead; the newer session is still preserved.

Handle a discarded successful result with AuthRefreshDiscardedError.is:

import { AuthRefreshDiscardedError, AuthSessionChangedError, VolcanoAuth } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
  apiUrl: 'https://api.volcano.dev',
  anonKey: process.env.VOLCANO_ANON_KEY,
});

const { user, error } = await volcano.auth.getUser();

if (AuthRefreshDiscardedError.is(error)) {
  // Auth state changed while this request was pending. Read the current state
  // or ask the user to retry; do not automatically replay a mutation.
  const { data } = await volcano.auth.getSession();
  console.log('Current session:', data.session);
} else if (AuthSessionChangedError.is(error)) {
  // The user response belongs to a session that was replaced while the
  // request was pending.
  const { data } = await volcano.auth.getSession();
  console.log('Current session:', data.session);
} else if (error) {
  console.error('Unable to load the user:', error.message);
} else {
  console.log('Signed in as:', user.email);
}

getUser() and other auth operations return AuthSessionChangedError when a newer logical session wins before their result can be committed. Treat the current session as authoritative instead of reporting the stale transition as successful. This includes signOut() when a separate sign-in or explicit session adoption replaces the captured session. A concurrent refresh belongs to the same server session and is cleared when sign-out finishes:

import { AuthSessionChangedError } from '@volcano.dev/sdk';

const { user, error } = await volcano.auth.signIn({
  email: 'alice@example.com',
  password: process.env.VOLCANO_PASSWORD,
});

if (AuthSessionChangedError.is(error)) {
  const { data } = await volcano.auth.getSession();
  console.log('Another auth operation established:', data.session);
} else if (error) {
  console.error('Sign-in failed:', error.message);
} else {
  console.log('Signed in as:', user.email);
}

Database Errors

Query Errors

const { data, error } = await volcano.from('posts').select('*').eq('status', 'published');

if (error) {
  switch (true) {
    case error.message.includes('No active session'):
      showError('Please sign in to view posts.');
      break;
    case error.message.includes('Database name not set'):
      console.error('Developer error: Call volcano.database() first');
      break;
    case error.message.includes('column') && error.message.includes('does not exist'):
      console.error('Developer error: Invalid column name in query');
      break;
    case error.message.includes('permission denied'):
      showError("You don't have permission to view this data.");
      break;
    case error.message.includes('timeout'):
      showError('Request timed out. Please try again.');
      break;
    default:
      showError('Failed to load data.');
      console.error('Query error:', error);
  }
  return;
}

Insert Errors

const { data, error } = await volcano.insert('posts', {
  title: 'My Post',
  content: 'Content here',
});

if (error) {
  switch (true) {
    case error.message.includes('violates unique constraint'):
      showError('A post with this title already exists.');
      break;
    case error.message.includes('violates foreign key'):
      showError('Invalid reference. Please check your data.');
      break;
    case error.message.includes('violates check constraint'):
      showError('Invalid data. Please check your input.');
      break;
    case error.message.includes('permission denied'):
      showError("You don't have permission to create posts.");
      break;
    default:
      showError('Failed to create post.');
      console.error('Insert error:', error);
  }
  return;
}

Update/Delete Errors

const { data, error } = await volcano.update('posts', { status: 'published' }).eq('id', postId);

if (error) {
  if (error.message.includes('permission denied')) {
    showError('You can only edit your own posts.');
    return;
  }
  showError('Failed to update post.');
  return;
}

// Check if any rows were updated
if (!data || data.length === 0) {
  showError('Post not found or already deleted.');
  return;
}

Storage Errors

const { data, error } = await volcano.storage.from('uploads').upload('documents/report.pdf', file);

if (error) {
  switch (true) {
    case error.message.includes('No active session'):
      showError('Please sign in to upload files.');
      break;
    case error.message.includes('Bucket not found'):
      console.error('Developer error: Invalid bucket name');
      break;
    case error.message.includes('File too large'):
      showError('File is too large. Maximum size is 100MB.');
      break;
    case error.message.includes('permission denied'):
      showError("You don't have permission to upload to this location.");
      break;
    case error.message.includes('invalid file type'):
      showError('This file type is not allowed.');
      break;
    default:
      showError('Upload failed. Please try again.');
      console.error('Upload error:', error);
  }
  return;
}

Download Errors

const { data: blob, error } = await volcano.storage
  .from('uploads')
  .download('documents/report.pdf');

if (error) {
  switch (true) {
    case error.message.includes('File not found'):
    case error.message.includes('404'):
      showError('File not found. It may have been deleted.');
      break;
    case error.message.includes('permission denied'):
      showError("You don't have permission to download this file.");
      break;
    default:
      showError('Download failed. Please try again.');
  }
  return;
}

Function Errors

const { data, error } = await volcano.functions.invoke('process-payment', {
  amount: 1999,
  currency: 'usd',
});

if (error) {
  // Platform-layer failures are a VolcanoSystemError (error.isSystemError ===
  // true): the call never reached your function — deploy down/failed, gateway
  // error, rate limit, timeout, or network failure. error.status is the HTTP
  // status (null for transport failures).
  if (error.isSystemError) {
    switch (true) {
      case error.status === 429:
        showError('Too many requests. Please wait a moment.');
        break;
      case error.status === null:
        // Transport failure (timeout, DNS, offline) — no HTTP status.
        showError('Network problem. Please check your connection and try again.');
        break;
      default:
        // Failed/provisioning deploy or gateway error (e.g. 400/503).
        showError('The service is temporarily unavailable. Please try again shortly.');
    }
    console.error('Platform error:', error.status, error.message);
    return;
  }

  // Pre-flight / caller errors stay plain Error (message is case-sensitive).
  switch (true) {
    case error.message.includes('function not found'):
      console.error('Developer error: Invalid function name');
      break;
    default:
      showError('Operation failed. Please try again.');
      console.error('Function error:', error);
  }
  return;
}

// A running function's own error comes back in the response body, not `error`.
if (data && data.error) {
  showError(data.error);
  return;
}

Durable Execution Errors

Every durable call answers { data, status, error } and never throws. status is what separates the cases: the platform's HTTP status when the request reached it, and null when it did not — a refusal the SDK made before sending, or a transport failure.

const { data, status, error } = await volcano.durable.start(
  'order-pipeline',
  { order_id: orderId },
  { executionName: `order-${orderId}` },
);

if (error) {
  switch (status) {
    case 409:
      // The function is still provisioning after a deploy, it has no deployed
      // region yet, or two starts raced for one name. All three clear on their
      // own, so the same start works shortly.
      //
      // A repeated executionName is not an error: the name is the idempotency
      // key, so a retry answers 202 with the execution the first start created,
      // whatever input the retry carried.
      scheduleRetry();
      break;
    case 429:
      // The project is at its concurrent-execution cap or out of allowance.
      showError('Too much work in flight. Try again shortly.');
      break;
    case 403:
    case 404:
      // A private function started with an anon key, or a name this project
      // has no durable function for. Neither improves on a retry.
      console.error('Developer error:', error.message);
      break;
    case null:
      // Nothing was sent: a blank function name, a blank execution name, or
      // the network. `error.message` says which.
      console.error('Start not attempted:', error.message);
      break;
    default:
      showError('Could not start the pipeline.');
  }
  return;
}

console.log(data.id, data.status);

get, list and stop are owner-scoped, so they refuse with status: null and No active session when the client holds no session rather than spending a round trip on the 401. Call them from a backend signed in with the project's token.

An execution that ran and failed is not an error here: the call succeeds and data.status is failed or timed_out, with data.error carrying the type and message the function ended on.

const { data, error } = await volcano.durable.get(projectId, 'order-pipeline', executionId);

if (!error && data.status === 'failed') {
  console.error('Pipeline failed:', data.error?.type, data.error?.message);
}

Realtime Errors

Connection Errors

const realtime = new VolcanoRealtime({ ... });

realtime.onError((ctx) => {
  console.error('Connection error:', ctx.message);

  if (ctx.message?.includes('authentication')) {
    // Token may have expired
    refreshTokenAndReconnect();
  } else if (ctx.message?.includes('network')) {
    showError('Connection lost. Reconnecting...');
  }
});

realtime.onDisconnect((ctx) => {
  if (!ctx.reconnect) {
    // Won't auto-reconnect, handle manually
    showError('Connection closed.');
  }
});

Subscription Errors

const channel = realtime.channel('posts', { type: 'postgres' });

try {
  await channel.subscribe();
} catch (error) {
  console.error('Subscription failed:', error.message);
  showError('Failed to subscribe to updates.');
}

Creating Custom Error Handlers

Centralized Error Handler

// lib/errorHandler.js
export function handleApiError(error, context = 'Operation') {
  // Log for debugging
  console.error(`${context} error:`, error);

  // Common errors
  if (error.message.includes('No active session')) {
    return {
      message: 'Please sign in to continue.',
      action: 'redirect_login',
    };
  }

  if (error.message.includes('permission denied')) {
    return {
      message: "You don't have permission to perform this action.",
      action: 'show_error',
    };
  }

  if (error.message.includes('timeout')) {
    return {
      message: 'Request timed out. Please try again.',
      action: 'retry',
    };
  }

  if (error.message.includes('rate limit')) {
    return {
      message: 'Too many requests. Please wait a moment.',
      action: 'wait',
    };
  }

  // Default
  return {
    message: `${context} failed. Please try again.`,
    action: 'show_error',
  };
}

// Usage
const { data, error } = await volcano.from('posts').select('*');

if (error) {
  const { message, action } = handleApiError(error, 'Loading posts');

  switch (action) {
    case 'redirect_login':
      router.push('/login');
      break;
    case 'retry':
      // Implement retry logic
      break;
    default:
      showToast(message, 'error');
  }
  return;
}

React Error Hook

// hooks/useApiCall.ts
import { useState, useCallback } from 'react';

interface ApiCallState<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
}

export function useApiCall<T>() {
  const [state, setState] = useState<ApiCallState<T>>({
    data: null,
    error: null,
    loading: false,
  });

  const execute = useCallback(async (
    apiCall: () => Promise<{ data: T | null; error: Error | null }>
  ) => {
    setState({ data: null, error: null, loading: true });

    try {
      const result = await apiCall();

      if (result.error) {
        setState({ data: null, error: result.error, loading: false });
        return { data: null, error: result.error };
      }

      setState({ data: result.data, error: null, loading: false });
      return { data: result.data, error: null };
    } catch (e) {
      const error = e instanceof Error ? e : new Error('Unknown error');
      setState({ data: null, error, loading: false });
      return { data: null, error };
    }
  }, []);

  return { ...state, execute };
}

// Usage
function PostList() {
  const { data: posts, error, loading, execute } = useApiCall<Post[]>();

  useEffect(() => {
    execute(() =>
      volcano
        .from<Post>('posts')
        .select('*')
        .order('created_at', { ascending: false })
    );
  }, [execute]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!posts) return <Empty />;

  return <PostGrid posts={posts} />;
}

Retry Strategies

Simple Retry

async function fetchWithRetry(fn, maxRetries = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const { data, error } = await fn();

    if (!error) {
      return { data, error: null };
    }

    lastError = error;

    // Don't retry certain errors
    if (
      error.message.includes('permission denied') ||
      error.message.includes('invalid') ||
      error.message.includes('not found')
    ) {
      return { data: null, error };
    }

    // Wait before retrying (exponential backoff)
    if (attempt < maxRetries) {
      await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000));
    }
  }

  return { data: null, error: lastError };
}

// Usage
const { data, error } = await fetchWithRetry(() => volcano.from('posts').select('*'));

Retry with Toast Notification

async function fetchWithProgress(fn, options = {}) {
  const { maxRetries = 3, context = 'Loading' } = options;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const { data, error } = await fn();

    if (!error) {
      return { data, error: null };
    }

    if (attempt < maxRetries && error.message.includes('timeout')) {
      showToast(`${context} is taking longer than expected. Retrying...`, 'info');
      await new Promise((resolve) => setTimeout(resolve, 2000));
      continue;
    }

    return { data: null, error };
  }
}

Best Practices

1. Always Check Errors

// Good
const { data, error } = await volcano.from('posts').select('*');
if (error) {
  handleError(error);
  return;
}
// Use data safely

// Avoid
const { data } = await volcano.from('posts').select('*');
// data could be null if there was an error

2. Provide Meaningful Messages

// Good
if (error.message.includes('permission denied')) {
  showError('You can only view your own posts.');
}

// Avoid
if (error) {
  showError(error.message); // May expose technical details
}

3. Log for Debugging

if (error) {
  // Log the full error for debugging
  console.error('Failed to load posts:', error);

  // Show a user-friendly message
  showError('Unable to load posts. Please try again.');
}

4. Handle Network Issues

const { data, error } = await volcano.from('posts').select('*');

if (error) {
  if (!navigator.onLine) {
    showError('You appear to be offline. Please check your connection.');
  } else if (error.message.includes('timeout')) {
    showError('Connection is slow. Please try again.');
  } else {
    showError('Something went wrong. Please try again.');
  }
}

5. Clean Up on Error

setLoading(true);
setError(null);

const { data, error } = await volcano.from('posts').select('*');

setLoading(false);

if (error) {
  setError(error.message);
  setData(null); // Clear stale data
  return;
}

setData(data);

Next Steps

On this page