| name | volcano-error-handling |
| description | Reusable error handling patterns for Volcano SDK apps: centralized error dispatcher with action enum, useApiCall React hook for loading/error/data state, retry with exponential backoff, retry with toast notifications, and cross-domain error message catalog. |
Volcano Error Handling Skill
Role
Provide the reusable error-handling infrastructure for Volcano SDK apps. The other domain skills (volcano_auth, volcano_database, etc.) cover error MESSAGES per domain; this skill covers error-handling PRIMITIVES that the rest of the codebase composes on:
- A centralized error dispatcher that returns an
action enum.
- A
useApiCall<T> React hook for loading/error/data lifecycle.
- Retry utilities with exponential backoff.
- Toast-aware retry for slow-but-recoverable operations.
When to use
- Building any non-trivial UI that fetches Volcano data.
- Refactoring scattered
if (error) return ... into a single dispatcher.
- Adding retry around transient failures (timeouts, rate limits).
- Establishing the project's loading/error/data UI conventions.
The Volcano Error Pattern (recap)
Every SDK method returns { data, error }. error is Error | null. NEVER wrap SDK calls in try/catch expecting throws — the error comes back in the response object. Some methods (functions, downloads) include richer fields (status, headers, version, etc.); the error field is always present.
Realtime is the one exception: await channel.subscribe() may throw, and realtime.onError(ctx => ...) is the connection-level handler. See the Realtime section below.
Centralized Error Dispatcher
The single most useful primitive: a function that maps a raw error to a { message, action } pair where action is a small enum. This decouples error MEANING from error PRESENTATION.
export type ErrorAction =
| 'redirect_login'
| 'show_error'
| 'retry'
| 'wait';
export interface ErrorDecision {
message: string;
action: ErrorAction;
}
export function handleApiError(error: Error, context = 'Operation'): ErrorDecision {
console.error(`${context} error:`, error);
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' };
}
return { message: `${context} failed. Please try again.`, action: 'show_error' };
}
Using the dispatcher
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':
break;
case 'wait':
showToast(message, 'info');
break;
default:
showToast(message, 'error');
}
return;
}
When to extend the dispatcher
Add a new branch when:
- A new error class (e.g.,
'quota exceeded') needs a distinct user-visible action.
- Two existing branches collapse to the same action and message — replace the branch with a single check.
Don't add domain-specific messages here (those belong in the per-domain skills' Common Errors tables); keep the dispatcher cross-cutting.
React Hook: useApiCall<T>
The canonical loading/error/data hook. Use it everywhere you fetch Volcano data inside a component.
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} />;
}
The pattern: render-as-state (loading/error/data), and the hook moves you between those states atomically. Three early returns at the top of the component keep the happy path linear.
Retry with Exponential Backoff
For transient failures (timeouts, network blips, brief rate limits), wrap the call in fetchWithRetry. Note the explicit allowlist of NON-retryable errors — re-trying permission denied or not found will never succeed and just delays user feedback.
type ApiResult<T> = { data: T | null; error: Error | null };
export async function fetchWithRetry<T>(
fn: () => Promise<ApiResult<T>>,
maxRetries = 3,
): Promise<ApiResult<T>> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const { data, error } = await fn();
if (!error) return { data, error: null };
lastError = error;
if (
error.message.includes('permission denied') ||
error.message.includes('invalid') ||
error.message.includes('not found')
) {
return { data: null, error };
}
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('*'),
);
Tuning
- Default
maxRetries = 3 — covers ~14 seconds total wait. Past that, the user is better served by a clear error than more silent waiting.
- Don't retry mutations blindly. Reads are idempotent; inserts/updates/deletes can produce duplicate rows or partial state on retry. Either retry only on confirmed-network errors, or design the mutation to be idempotent (e.g.,
INSERT ... ON CONFLICT DO NOTHING server-side).
Retry with Toast Notification (slow-but-recoverable)
For operations the user is explicitly waiting on (a button click, a save), use a variant that surfaces progress in the UI:
interface RetryProgressOptions {
maxRetries?: number;
context?: string;
}
export async function fetchWithProgress<T>(
fn: () => Promise<ApiResult<T>>,
options: RetryProgressOptions = {},
): Promise<ApiResult<T>> {
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 };
}
return { data: null, error: new Error('retry exhausted') };
}
This variant only retries timeout errors and surfaces a toast each retry. Use it when the user is actively watching the screen.
Realtime Error Handling
Realtime is the only place errors come through callbacks (or thrown subscriptions) instead of { data, error }.
Connection-level errors
const realtime = new VolcanoRealtime({ });
realtime.onError((ctx) => {
console.error('Connection error:', ctx.message);
if (ctx.message?.includes('authentication')) {
refreshTokenAndReconnect();
} else if (ctx.message?.includes('network')) {
showError('Connection lost. Reconnecting...');
}
});
realtime.onDisconnect((ctx) => {
if (!ctx.reconnect) {
showError('Connection closed.');
}
});
Subscription-level errors
const channel = realtime.channel('posts', { type: 'postgres' });
try {
await channel.subscribe();
} catch (error) {
console.error('Subscription failed:', (error as Error).message);
showError('Failed to subscribe to updates.');
}
subscribe() throws on failure (one of the few SDK methods that does). Wrap it in try/catch.
Per-Domain Error Catalogs (cross-reference)
Each domain skill includes domain-specific error messages. This skill is the cross-cutting infrastructure; consult the domain skills for the specific message strings to match against:
| Domain | Skill | Common error messages |
|---|
| Auth | volcano_auth | invalid email or password, email not confirmed, already exists, weak password, too many attempts |
| Database | volcano_database | column does not exist, permission denied, No active session, Database name not set, violates unique constraint |
| Functions | volcano_functions | Function not found, timeout, rate limit, Internal server error (or business-logic data.error) |
| Storage | volcano_storage | No active session, Bucket not found, File not found, File too large, permission denied, invalid file type |
| Realtime | volcano_realtime | authentication (token expired), network, opaque WebSocket errors (often CORS — see realtime skill's Browser Origins section) |
When a new error message starts appearing in production logs, add it to the relevant domain skill's catalog and — if it warrants a distinct action — to handleApiError here.
Best Practices
Always check errors
const { data, error } = await volcano.from('posts').select('*');
if (error) {
handleError(error);
return;
}
const { data } = await volcano.from('posts').select('*');
Provide meaningful user-facing messages
Don't surface raw error.message to users. Map it through handleApiError to a UI-appropriate string. Raw messages may leak schema details ("column internal_flag does not exist") or vendor terminology ("RLS policy violation").
Log the full error for debugging
The dispatcher's console.error(...) line is non-negotiable. Production logs need the original error to diagnose; the user just needs the friendly message.
Handle network issues explicitly
if (error) {
if (typeof navigator !== 'undefined' && !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.');
}
}
Clean up state on error
After an error, clear any stale data so the UI doesn't show partial state mixed with a failure indicator:
setLoading(true);
setError(null);
const { data, error } = await volcano.from('posts').select('*');
setLoading(false);
if (error) {
setError(error.message);
setData(null);
return;
}
setData(data);
useApiCall does this for you automatically.
Verification Checklist
- A single
handleApiError (or equivalent) lives next to the UI code (web/lib/errors.ts for a web/ frontend, or the existing shared UI helper path otherwise) and is used everywhere.
- The action enum is small and exhaustive (
redirect_login, show_error, retry, wait) — no per-component string actions.
- Components use
useApiCall<T> for fetched data, not bespoke useState({loading, error, data}).
fetchWithRetry is wrapped around reads only, OR mutations are designed idempotent.
realtime.onError is wired and discriminates between auth and network failure modes.
await channel.subscribe() is wrapped in try/catch.
- No raw
error.message leaks to the user UI; all messages are dispatched through handleApiError.
Companion Skills
volcano_typescript — for type-safe error handling (Error, ApiResult<T>, generic shape).
- Domain skills — for the specific error-message strings per-domain.
volcano_realtime — for the WebSocket-specific connection error semantics (Browser Origins/CORS gotcha).