← VolcanoCONTENT HISTORY
WHAT CHANGED
Update to Volcano
Snapshot Oct 24, 2026 · 13:36 UTC · version 0.2.26
Collection source: downloaded plugin package.
First saved snapshot
No earlier snapshot is available to establish a change.
Save Volcano and check future changes in Following.
Up to 10 plugins free · no card required.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"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.",
"included_files": [],
"name": "volcano-error-handling",
"skill_md_contents": "---\nname: volcano-error-handling\ndescription: \"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.\"\n---\n# Volcano Error Handling Skill\n\n## Role\nProvide the reusable error-handling infrastructure for Volcano SDK apps. Pair with `volcano-uiux` when errors, retries, or progress appear in a user-facing interface. 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:\n\n- A centralized error dispatcher that returns an `action` enum.\n- A `useApiCall<T>` React hook for `loading`/`error`/`data` lifecycle.\n- Retry utilities with exponential backoff.\n- Toast-aware retry for slow-but-recoverable operations.\n\n## When to use\n- Building any non-trivial UI that fetches Volcano data.\n- Refactoring scattered `if (error) return ...` into a single dispatcher.\n- Adding retry around transient failures (timeouts, rate limits).\n- Establishing the project's loading/error/data UI conventions.\n\n## The Volcano Error Pattern (recap)\nEvery 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.\n\nRealtime is the one exception: `await channel.subscribe()` may throw, and `realtime.onError(ctx => ...)` is the connection-level handler. See the Realtime section below.\n\n## Centralized Error Dispatcher\n\nThe 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.\n\n```ts\n// Place next to the UI code: web/lib/errors.ts for a web/ frontend,\n// or the existing shared UI helper path for a non-web app.\nexport type ErrorAction =\n | 'redirect_login'\n | 'show_error'\n | 'retry'\n | 'wait';\n\nexport interface ErrorDecision {\n message: string;\n action: ErrorAction;\n}\n\nexport function handleApiError(error: Error, context = 'Operation'): ErrorDecision {\n console.error(`${context} error:`, error);\n\n if (error.message.includes('No active session')) {\n return { message: 'Please sign in to continue.', action: 'redirect_login' };\n }\n if (error.message.includes('permission denied')) {\n return { message: `You don't have permission to perform this action.`, action: 'show_error' };\n }\n if (error.message.includes('timeout')) {\n return { message: 'Request timed out. Please try again.', action: 'retry' };\n }\n if (error.message.includes('rate limit')) {\n return { message: 'Too many requests. Please wait a moment.', action: 'wait' };\n }\n\n return { message: `${context} failed. Please try again.`, action: 'show_error' };\n}\n```\n\n### Using the dispatcher\n```ts\nconst { data, error } = await volcano.from('posts').select('*');\n\nif (error) {\n const { message, action } = handleApiError(error, 'Loading posts');\n switch (action) {\n case 'redirect_login':\n router.push('/login');\n break;\n case 'retry':\n // queue a retry — see useApiCall + fetchWithRetry below\n break;\n case 'wait':\n showToast(message, 'info');\n break;\n default:\n showToast(message, 'error');\n }\n return;\n}\n```\n\n### When to extend the dispatcher\nAdd a new branch when:\n- A new error class (e.g., `'quota exceeded'`) needs a distinct user-visible action.\n- Two existing branches collapse to the same action and message — replace the branch with a single check.\n\nDon't add domain-specific messages here (those belong in the per-domain skills' Common Errors tables); keep the dispatcher cross-cutting.\n\n## React Hook: `useApiCall<T>`\n\nThe canonical loading/error/data hook. Use it everywhere you fetch Volcano data inside a component.\n\n```tsx\n// hooks/useApiCall.ts\nimport { useState, useCallback } from 'react';\n\ninterface ApiCallState<T> {\n data: T | null;\n error: Error | null;\n loading: boolean;\n}\n\nexport function useApiCall<T>() {\n const [state, setState] = useState<ApiCallState<T>>({\n data: null,\n error: null,\n loading: false,\n });\n\n const execute = useCallback(async (\n apiCall: () => Promise<{ data: T | null; error: Error | null }>,\n ) => {\n setState({ data: null, error: null, loading: true });\n try {\n const result = await apiCall();\n if (result.error) {\n setState({ data: null, error: result.error, loading: false });\n return { data: null, error: result.error };\n }\n setState({ data: result.data, error: null, loading: false });\n return { data: result.data, error: null };\n } catch (e) {\n const error = e instanceof Error ? e : new Error('Unknown error');\n setState({ data: null, error, loading: false });\n return { data: null, error };\n }\n }, []);\n\n return { ...state, execute };\n}\n```\n\n### Usage\n```tsx\nimport { useEffect, useState } from 'react';\n\nfunction LoadingPosts() {\n const [visible, setVisible] = useState(false);\n\n useEffect(() => {\n const timer = window.setTimeout(() => setVisible(true), 200);\n return () => window.clearTimeout(timer);\n }, []);\n\n if (!visible) return null;\n return <div role=\"status\"><Spinner aria-hidden=\"true\" /> Loading posts…</div>;\n}\n\nfunction PostList() {\n const { data: posts, error, loading, execute } = useApiCall<Post[]>();\n\n useEffect(() => {\n execute(() =>\n volcano\n .from<Post>('posts')\n .select('*')\n .order('created_at', { ascending: false }),\n );\n }, [execute]);\n\n if (loading) return <LoadingPosts />;\n if (error) return <ErrorMessage error={error} />;\n if (!posts?.length) return <Empty />;\n\n return <PostGrid posts={posts} />;\n}\n```\n\nThe 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.\n\n## Retry with Exponential Backoff\n\nFor 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.\n\n```ts\n// Place next to the UI code: web/lib/retry.ts for a web/ frontend,\n// or the existing shared UI helper path for a non-web app.\ntype ApiResult<T> = { data: T | null; error: Error | null };\n\nexport async function fetchWithRetry<T>(\n fn: () => Promise<ApiResult<T>>,\n maxRetries = 3,\n): Promise<ApiResult<T>> {\n let lastError: Error | null = null;\n\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n const { data, error } = await fn();\n if (!error) return { data, error: null };\n lastError = error;\n\n // Non-retryable errors: bail immediately.\n if (\n error.message.includes('permission denied') ||\n error.message.includes('invalid') ||\n error.message.includes('not found')\n ) {\n return { data: null, error };\n }\n\n // Exponential backoff: 2s, 4s, 8s ...\n if (attempt < maxRetries) {\n await new Promise(resolve =>\n setTimeout(resolve, Math.pow(2, attempt) * 1000),\n );\n }\n }\n\n return { data: null, error: lastError };\n}\n```\n\n### Usage\n```ts\nconst { data, error } = await fetchWithRetry(() =>\n volcano.from('posts').select('*'),\n);\n```\n\n### Tuning\n- **Default `maxRetries = 3`** — covers ~14 seconds total wait. Past that, the user is better served by a clear error than more silent waiting.\n- **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).\n\n## Retry with Toast Notification (slow-but-recoverable)\n\nFor operations the user is explicitly waiting on (a button click, a save), use a variant that surfaces progress in the UI:\n\n```ts\ninterface RetryProgressOptions {\n maxRetries?: number;\n context?: string;\n}\n\nexport async function fetchWithProgress<T>(\n fn: () => Promise<ApiResult<T>>,\n options: RetryProgressOptions = {},\n): Promise<ApiResult<T>> {\n const { maxRetries = 3, context = 'Loading' } = options;\n\n for (let attempt = 1; attempt <= maxRetries; attempt++) {\n const { data, error } = await fn();\n if (!error) return { data, error: null };\n\n if (attempt < maxRetries && error.message.includes('timeout')) {\n showToast(`${context} is taking longer than expected. Retrying...`, 'info');\n await new Promise(resolve => setTimeout(resolve, 2000));\n continue;\n }\n return { data: null, error };\n }\n // unreachable in practice; satisfy the type checker:\n return { data: null, error: new Error('retry exhausted') };\n}\n```\n\nThis variant only retries `timeout` errors and surfaces a toast each retry. Use it when the user is actively watching the screen.\n\n## Realtime Error Handling\n\nRealtime is the only place errors come through callbacks (or thrown subscriptions) instead of `{ data, error }`.\n\n### Connection-level errors\n```ts\nconst realtime = new VolcanoRealtime({ /* ... */ });\n\nrealtime.onError((ctx) => {\n console.error('Connection error:', ctx.message);\n if (ctx.message?.includes('authentication')) {\n refreshTokenAndReconnect(); // token expired\n } else if (ctx.message?.includes('network')) {\n showError('Connection lost. Reconnecting...');\n }\n});\n\nrealtime.onDisconnect((ctx) => {\n if (!ctx.reconnect) {\n // SDK will NOT auto-reconnect. Handle manually if you want recovery.\n showError('Connection closed.');\n }\n});\n```\n\n### Subscription-level errors\n```ts\nconst channel = realtime.channel('posts', { type: 'postgres' });\ntry {\n await channel.subscribe();\n} catch (error) {\n console.error('Subscription failed:', (error as Error).message);\n showError('Failed to subscribe to updates.');\n}\n```\n\n`subscribe()` throws on failure (one of the few SDK methods that does). Wrap it in try/catch.\n\n## Per-Domain Error Catalogs (cross-reference)\n\nEach 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:\n\n| Domain | Skill | Common error messages |\n|---|---|---|\n| Auth | `volcano-auth` | `invalid email or password`, `confirm your email`, `already exists`, `password must`, `rate limit` |\n| Database | `volcano-database` | `column does not exist`, `permission denied`, `No active session`, `Database name not set`, `violates unique constraint` |\n| Functions | `volcano-functions` | `Function not found`, `timeout`, `rate limit`, `Internal server error` (or business-logic `data.error`) |\n| Logs | `volcano-logs` | project-token authentication, invalid cursor, invalid resource or query filters |\n| Locks | `volcano-locks` | `lock_held`, `lock_ownership_lost`, `lock_rate_limited` |\n| Storage | `volcano-storage` | `No active session`, `Bucket not found`, `File not found`, `File too large`, `permission denied`, `invalid file type` |\n| Realtime | `volcano-realtime` | `authentication` (token expired), `network`, opaque WebSocket errors (often CORS — see realtime skill's Browser Origins section) |\n\nWhen 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.\n\n## Best Practices\n\n### Always check errors\n```ts\n// Good\nconst { data, error } = await volcano.from('posts').select('*');\nif (error) {\n handleError(error);\n return;\n}\n// data is non-null here\n\n// Bad\nconst { data } = await volcano.from('posts').select('*');\n// data could be null\n```\n\n### Provide meaningful user-facing messages\nDon'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\").\n\n### Log the full error for debugging\nThe dispatcher's `console.error(...)` line is non-negotiable. Production logs need the original error to diagnose; the user just needs the friendly message.\n\n### Handle network issues explicitly\n```ts\nif (error) {\n if (typeof navigator !== 'undefined' && !navigator.onLine) {\n showError('You appear to be offline. Please check your connection.');\n } else if (error.message.includes('timeout')) {\n showError('Connection is slow. Please try again.');\n } else {\n showError('Something went wrong. Please try again.');\n }\n}\n```\n\n### Clean up state on error\nAfter an error, clear any stale data so the UI doesn't show partial state mixed with a failure indicator:\n```ts\nsetLoading(true);\nsetError(null);\n\nconst { data, error } = await volcano.from('posts').select('*');\n\nsetLoading(false);\nif (error) {\n setError(error.message);\n setData(null); // <-- clear stale data\n return;\n}\nsetData(data);\n```\n`useApiCall` does this for you automatically.\n\n## Verification Checklist\n- 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.\n- The action enum is small and exhaustive (`redirect_login`, `show_error`, `retry`, `wait`) — no per-component string actions.\n- Components use `useApiCall<T>` for fetched data, not bespoke `useState({loading, error, data})`.\n- `fetchWithRetry` is wrapped around reads only, OR mutations are designed idempotent.\n- `realtime.onError` is wired and discriminates between auth and network failure modes.\n- `await channel.subscribe()` is wrapped in try/catch.\n- No raw `error.message` leaks to the user UI; all messages are dispatched through `handleApiError`.\n\n## Companion Skills\n- `volcano-uiux` — for user-facing progress, error, and retry feedback.\n- `volcano-typescript` — for type-safe error handling (`Error`, `ApiResult<T>`, generic shape).\n- Domain skills — for the specific error-message strings per-domain.\n- `volcano-realtime` — for the WebSocket-specific connection error semantics (Browser Origins/CORS gotcha).\n"
}SHA-256 of public snapshot: 643acaad62cf5bb0b1f848f0ae26873183f34df54c7a5ea39c01e235a0cd1db1