error-handling
Patterns for robust error handling across TypeScript, Python, and Go. Covers typed errors, error boundaries, retries, circuit breakers, and user-facing error messages.
Error Handling Patterns
Consistent, robust error handling patterns for production applications.
When to Activate
- Designing error types or exception hierarchies for a new module or service
- Adding retry logic or circuit breakers for unreliable external dependencies
- Reviewing API endpoints for missing error handling
- Implementing user-facing error messages and feedback
- Debugging cascading failures or silent error swallowing
Core Principles
- Fail fast and loudly — surface errors at the boundary where they occur; don’t bury them
- Typed errors over string messages — errors are first-class values with structure
- User messages ≠ developer messages — show friendly text to users, log full context server-side
- Never swallow errors silently — every
catchblock must either handle, re-throw, or log - Errors are part of your API contract — document every error code a client may receive
TypeScript / JavaScript
Typed Error Classes
// Define an error hierarchy for your domainexport 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) }}Result Pattern (no-throw style)
For operations where failure is expected and common (parsing, external calls):
type 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 }}
// Usageasync 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 hereconsole.log(result.value.email)API Error Handler (Next.js / Express)
import { 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) }}React Error Boundary
import { 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>Python
Custom Exception Hierarchy
class 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 []FastAPI Global Exception Handler
from fastapi import FastAPI, Requestfrom 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"}}, )Go
Sentinel Errors and Error Wrapping
package domain
import "errors"
// Sentinel errors for type-checkingvar ( ErrNotFound = errors.New("not found") ErrUnauthorized = errors.New("unauthorized") ErrConflict = errors.New("conflict"))
// Wrap errors with context — never lose the originalfunc (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 responsefunc (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)}Retry with Exponential Backoff
interface 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 4xxconst data = await withRetry(() => fetch('/api/data').then(r => r.json()), { maxAttempts: 3, retryIf: (error) => !(error instanceof AppError && error.statusCode < 500),})User-Facing Error Messages
Map error codes to human-readable messages. Keep technical details out of user-visible text.
const 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}Error Handling Checklist
Before merging any code that touches error handling:
- Every
catchblock handles, re-throws, or logs — no silent swallowing - API errors follow the standard envelope
{ error: { code, message } } - User-facing messages contain no stack traces or internal details
- Full error context is logged server-side
- Custom error classes extend a base
AppErrorwith acodefield - Async functions surface errors to callers — no fire-and-forget without fallback
- Retry logic only retries retriable errors (not 4xx client errors)
- React components are wrapped in
ErrorBoundaryfor rendering errors