Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Patterns for robust error handling across TypeScript, Python, and Go. Covers typed errors, error boundaries, retries, circuit breakers, and user-facing error messages.
.claude/skills/error-handling/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flash | 83% | 60 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
| case-06 | ✗→✓ | ▲ Improved | — | — |
Consistent, robust error handling patterns for production applications.
catch block must either handle, re-throw, or logtypescript// Define an error hierarchy for your domain export class AppError extends Error { constructor( message: string, public readonly code: string, public readonly statusCode: number = 500, public readonly details?: unknown, ) { super(message) this.name = this.constructor.name // Maintain correct prototype chain in transpiled ES5 JavaScript. // Required for `instanceof` checks (e.g., `error instanceof NotFoundError`) // to work correctly when extending the built-in Error class. Object.setPrototypeOf(this, new.target.prototype) } } export class NotFoundError extends AppError { constructor(resource: string, id: string) { super(`${resource} not found: ${id}`, 'NOT_FOUND', 404) } } export class ValidationError extends AppError { constructor(message: string, details: { field: string; message: string }[]) { super(message, 'VALIDATION_ERROR', 422, details) } } export class UnauthorizedError extends AppError { constructor(reason = 'Authentication required') { super(reason, 'UNAUTHORIZED', 401) } } export class RateLimitError extends AppError { constructor(public readonly retryAfterMs: number) { super('Rate limit exceeded', 'RATE_LIMITED', 429) } }
For operations where failure is expected and common (parsing, external calls):
typescripttype Result<T, E = AppError> = | { ok: true; value: T } | { ok: false; error: E } function ok<T>(value: T): Result<T> { return { ok: true, value } } function err<E>(error: E): Result<never, E> { return { ok: false, error } } // Usage async function fetchUser(id: string): Promise<Result<User>> { try { const user = await db.users.findUnique({ where: { id } }) if (!user) return err(new NotFoundError('User', id)) return ok(user) } catch (e) { return err(new AppError('Database error', 'DB_ERROR')) } } const result = await fetchUser('abc-123') if (!result.ok) { // TypeScript knows result.error here logger.error('Failed to fetch user', { error: result.error }) return } // TypeScript knows result.value here console.log(result.value.email)
typescriptimport { NextRequest, NextResponse } from 'next/server' function handleApiError(error: unknown): NextResponse { // Known application error if (error instanceof AppError) { return NextResponse.json( { error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}), }, }, { status: error.statusCode }, ) } // Zod validation error if (error instanceof z.ZodError) { return NextResponse.json( { error: { code: 'VALIDATION_ERROR', message: 'Request validation failed', details: error.issues.map(i => ({ field: i.path.join('.'), message: i.message, })), }, }, { status: 422 }, ) } // Unexpected error — log details, return generic message console.error('Unexpected error:', error) return NextResponse.json( { error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } }, { status: 500 }, ) } export async function POST(req: NextRequest) { try { // ... handler logic } catch (error) { return handleApiError(error) } }
typescriptimport { Component, ErrorInfo, ReactNode } from 'react' interface Props { fallback: ReactNode onError?: (error: Error, info: ErrorInfo) => void children: ReactNode } interface State { hasError: boolean error: Error | null } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null } static getDerivedStateFromError(error: Error): State { return { hasError: true, error } } componentDidCatch(error: Error, info: ErrorInfo) { this.props.onError?.(error, info) console.error('Unhandled React error:', error, info) } render() { if (this.state.hasError) return this.props.fallback return this.props.children } } // Usage <ErrorBoundary fallback={<p>Something went wrong. Please refresh.</p>}> <MyComponent /> </ErrorBoundary>
pythonclass AppError(Exception): """Base application error.""" def __init__(self, message: str, code: str, status_code: int = 500): super().__init__(message) self.code = code self.status_code = status_code class NotFoundError(AppError): def __init__(self, resource: str, id: str): super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404) class ValidationError(AppError): def __init__(self, message: str, details: list[dict] | None = None): super().__init__(message, "VALIDATION_ERROR", 422) self.details = details or []
pythonfrom fastapi import FastAPI, Request from fastapi.responses import JSONResponse app = FastAPI() @app.exception_handler(AppError) async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content={"error": {"code": exc.code, "message": str(exc)}}, ) @app.exception_handler(Exception) async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse: # Log full details, return generic message logger.exception("Unexpected error", exc_info=exc) return JSONResponse( status_code=500, content={"error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}}, )
gopackage domain import "errors" // Sentinel errors for type-checking var ( ErrNotFound = errors.New("not found") ErrUnauthorized = errors.New("unauthorized") ErrConflict = errors.New("conflict") ) // Wrap errors with context — never lose the original func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) { user, err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("user %s: %w", id, ErrNotFound) } if err != nil { return nil, fmt.Errorf("querying user %s: %w", id, err) } return user, nil } // At the handler level, unwrap to determine response func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { user, err := h.service.GetUser(r.Context(), chi.URLParam(r, "id")) if err != nil { switch { case errors.Is(err, domain.ErrNotFound): writeError(w, http.StatusNotFound, "not_found", err.Error()) case errors.Is(err, domain.ErrUnauthorized): writeError(w, http.StatusForbidden, "forbidden", "Access denied") default: slog.Error("unexpected error", "err", err) writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred") } return } writeJSON(w, http.StatusOK, user) }
typescriptinterface RetryOptions { maxAttempts?: number baseDelayMs?: number maxDelayMs?: number retryIf?: (error: unknown) => boolean } async function withRetry<T>( fn: () => Promise<T>, options: RetryOptions = {}, ): Promise<T> { const { maxAttempts = 3, baseDelayMs = 500, maxDelayMs = 10_000, retryIf = () => true, } = options let lastError: unknown for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn() } catch (error) { lastError = error if (attempt === maxAttempts || !retryIf(error)) throw error const jitter = Math.random() * baseDelayMs const delay = Math.min(baseDelayMs * 2 ** (attempt - 1) + jitter, maxDelayMs) await new Promise(resolve => setTimeout(resolve, delay)) } } throw lastError } // Usage: retry transient network errors, not 4xx const data = await withRetry(() => fetch('/api/data').then(r => r.json()), { maxAttempts: 3, retryIf: (error) => !(error instanceof AppError && error.statusCode < 500), })
Map error codes to human-readable messages. Keep technical details out of user-visible text.
typescriptconst USER_ERROR_MESSAGES: Record<string, string> = { NOT_FOUND: 'The requested item could not be found.', UNAUTHORIZED: 'Please sign in to continue.', FORBIDDEN: "You don't have permission to do that.", VALIDATION_ERROR: 'Please check your input and try again.', RATE_LIMITED: 'Too many requests. Please wait a moment and try again.', INTERNAL_ERROR: 'Something went wrong on our end. Please try again later.', } export function getUserMessage(code: string): string { return USER_ERROR_MESSAGES[code] ?? USER_ERROR_MESSAGES.INTERNAL_ERROR }
Before merging any code that touches error handling:
catch block handles, re-throws, or logs — no silent swallowing{ error: { code, message } }AppError with a code fieldErrorBoundary for rendering errors| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +41 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.