mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-14 02:10:07 +08:00
377 lines
11 KiB
Markdown
377 lines
11 KiB
Markdown
---
|
|
name: error-handling
|
|
description: Patterns for robust error handling across TypeScript, Python, and Go. Covers typed errors, error boundaries, retries, circuit breakers, and user-facing error messages.
|
|
origin: ECC
|
|
---
|
|
|
|
# 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
|
|
|
|
1. **Fail fast and loudly** — surface errors at the boundary where they occur; don't bury them
|
|
2. **Typed errors over string messages** — errors are first-class values with structure
|
|
3. **User messages ≠ developer messages** — show friendly text to users, log full context server-side
|
|
4. **Never swallow errors silently** — every `catch` block must either handle, re-throw, or log
|
|
5. **Errors are part of your API contract** — document every error code a client may receive
|
|
|
|
## TypeScript / JavaScript
|
|
|
|
### Typed Error Classes
|
|
|
|
```typescript
|
|
// 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)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Result Pattern (no-throw style)
|
|
|
|
For operations where failure is expected and common (parsing, external calls):
|
|
|
|
```typescript
|
|
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 }
|
|
}
|
|
|
|
// 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)
|
|
```
|
|
|
|
### API Error Handler (Next.js / Express)
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
from 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"}},
|
|
)
|
|
```
|
|
|
|
## Go
|
|
|
|
### Sentinel Errors and Error Wrapping
|
|
|
|
```go
|
|
package 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)
|
|
}
|
|
```
|
|
|
|
## Retry with Exponential Backoff
|
|
|
|
```typescript
|
|
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 4xx
|
|
const 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.
|
|
|
|
```typescript
|
|
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 `catch` block 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 `AppError` with a `code` field
|
|
- [ ] 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 `ErrorBoundary` for rendering errors
|