examples/new/.opencode/skills/error-handling/SKILL.md
GraphQL error extraction, global toast handling via QueryCache/MutationCache, auth error auto-logout, and per-hook success toast patterns
npx skillsauth add aexol-studio/axolotl error-handlingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
frontend/src/api/errors.ts)Errors are typed via GraphQLErrorEntry interface (in errors.ts) — no as any:
extensions.code is string | undefinedgetGraphQLErrorCode(error) extracts the error code from extensionsimport { getGraphQLErrorMessage, getGraphQLErrorCode, isAuthError } from '../api';
getGraphQLErrorMessage(error); // → errors[0].message, or "An unexpected error occurred"
getGraphQLErrorCode(error); // → errors[0].extensions.code, or null
isAuthError(error); // → true if code is UNAUTHORIZED or FORBIDDEN
Never access extensions.originalError — dev-only, breaks in production.
frontend/src/lib/queryClient.ts)import { queryKeys } from './queryKeys.js';
let isHandlingAuthError = false;
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: async (error) => {
if (typeof window === 'undefined') return;
if (isAuthError(error) && !isHandlingAuthError) {
isHandlingAuthError = true;
toast.info('Session expired. Please log in again.');
await queryClient.cancelQueries();
mutation()({ user: { logout: true } }).catch(() => {}); // best-effort (non-awaited)
queryClient.setQueryData(queryKeys.me, null);
queryClient.clear();
window.location.href = '/login';
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (isAuthError(error)) return; // QueryCache handles it
toast.error(getGraphQLErrorMessage(error));
},
}),
});
window.location.href = '/login'toast.error() with extracted messageonError to individual hooks — the global handler covers itAuth errors must suppress retries — add to defaultOptions:
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (isAuthError(error)) return false;
return failureCount < 1;
},
},
},
const createMutation = useMutation({
mutationFn: async (input) => {
/* ... */
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.items });
toast.success('Item created!');
// ⛔ no onError here
},
});
// For form flow — return boolean so forms know success/failure
const submit = async (input) => {
try {
await createMutation.mutateAsync(input);
return true;
} catch {
return false;
} // error toast already shown globally
};
UNAUTHORIZED — missing/invalid auth token or sessionFORBIDDEN — valid session, insufficient permissionINVALID_CREDENTIALS — wrong email/password on loginEMAIL_EXISTS — registration with existing emailINVALID_INPUT — input fails validation rulesNOT_FOUND — requested resource doesn't existimport { GraphQLError } from 'graphql'; // NOT from graphql-yoga
throw new GraphQLError('Invalid email or password', {
extensions: { code: 'INVALID_CREDENTIALS' },
});
// Plain Error → masked to "Unexpected error." on client (use for internal crashes only)
GraphQL Yoga has error masking enabled by default — DO NOT disable it. Only GraphQLError messages reach the client; plain Error messages are masked to "Unexpected error." This is intentional for security.
tools
Baseline architecture for Axolotl mobile starter (Expo Router + reusable blocks)
tools
Expo Router conventions for route groups, native headers, and starter navigation
development
i18n baseline and dev-translate setup for Expo mobile starter
development
Starter data layer pattern with React Query + Zeus for Expo app