| name | error-handling |
| description | Use when designing error hierarchies, propagating errors across service boundaries, implementing retry and backoff logic, writing structured error responses for APIs, or making error handling consistent across a Python, TypeScript, or Go codebase. |
Error Handling
Patterns for structured errors, propagation, retries, and consistent failure surfaces across Python, TypeScript, and Go.
When to Activate
- Designing a custom exception or error hierarchy for a service
- Propagating errors across HTTP, gRPC, or queue boundaries
- Implementing retry logic with exponential backoff and jitter
- Writing consistent error responses for a REST or GraphQL API
- Distinguishing recoverable from unrecoverable errors
- Auditing a codebase for swallowed exceptions or bare
except blocks
- Adding context to errors without losing the original cause
Error Classification
Classify errors before deciding how to handle them — the right response depends on what kind of failure occurred.
| Class | Examples | Handle by |
|---|
| Validation | Missing field, bad format | Return 400 immediately — no retry |
| Not found | Unknown ID, deleted resource | Return 404 — no retry |
| Auth | Invalid token, expired session | Return 401/403 — no retry |
| Transient | Network timeout, DB connection blip | Retry with backoff |
| Dependency | Third-party API down, queue unavailable | Retry, then circuit-break |
| Logic bug | Null deref, assertion failure | Log + alert — do not retry or swallow |
| Rate limited | 429 from upstream | Retry after Retry-After header |
Custom Error Hierarchies
Python
class AppError(Exception):
def __init__(self, message: str, code: str, status: int = 500) -> None:
super().__init__(message)
self.code = code
self.status = status
class NotFoundError(AppError):
def __init__(self, resource: str, id: str) -> None:
super().__init__(f"{resource} '{id}' not found", "NOT_FOUND", 404)
class ValidationError(AppError):
def __init__(self, field: str, reason: str) -> None:
super().__init__(f"Invalid '{field}': {reason}", "VALIDATION_ERROR", 400)
class TransientError(AppError):
"""Signals a retry is safe."""
def __init__(self, message: str) -> None:
super().__init__(message, "TRANSIENT_ERROR", 503)
TypeScript
type AppError =
| { tag: "NOT_FOUND"; resource: string; id: string }
| { tag: "VALIDATION"; field: string; reason: string }
| { tag: "UNAUTHORIZED" }
| { tag: "TRANSIENT"; message: string; retryable: true };
function notFound(resource: string, id: string): AppError {
return { tag: "NOT_FOUND", resource, id };
}
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 }; }
async function getUser(id: string): Promise<Result<User>> {
const user = await db.findUser(id);
if (!user) return err(notFound("User", id));
return ok(user);
}
const result = await getUser("123");
if (!result.ok) {
switch (result.error.tag) {
case "NOT_FOUND": return res.status(404).json({ error: result.error });
case "UNAUTHORIZED": return res.status(401).json({ error: "Unauthorized" });
}
}
Go
var (
ErrNotFound = errors.New("not found")
ErrValidation = errors.New("validation error")
ErrTransient = errors.New("transient error")
)
type AppError struct {
Code string
Message string
Err error
}
func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }
func NotFound(resource, id string) *AppError {
return &AppError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s '%s' not found", resource, id),
Err: ErrNotFound,
}
}
func getUser(ctx context.Context, id string) (*User, error) {
user, err := db.FindUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, NotFound("User", id)
}
return nil, fmt.Errorf("getUser %s: %w", id, err)
}
return user, nil
}
if errors.Is(err, ErrNotFound) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if errors.Is(err, ErrTransient) {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
Adding Context Without Losing the Cause
try:
result = db.query(sql)
except psycopg2.OperationalError as exc:
raise TransientError("database unavailable") from exc
raise TransientError("database unavailable")
throw new Error("Database unavailable", { cause: originalError });
throw new Error("Database unavailable");
return fmt.Errorf("fetchOrders: %w", err)
return fmt.Errorf("fetchOrders: %v", err)
return errors.New("fetchOrders failed")
Retry with Exponential Backoff and Jitter
Jitter prevents thundering herd when many callers retry simultaneously.
import asyncio, random
async def with_retry(fn, *, max_attempts: int = 3, base_delay: float = 0.5):
for attempt in range(max_attempts):
try:
return await fn()
except TransientError:
if attempt == max_attempts - 1:
raise
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.3)
await asyncio.sleep(delay)
async function withRetry<T>(
fn: () => Promise<T>,
{ maxAttempts = 3, baseDelay = 500 } = {}
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (!isRetryable(err) || attempt === maxAttempts - 1) throw err;
const delay = baseDelay * 2 ** attempt + Math.random() * 300;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("unreachable");
}
function isRetryable(err: unknown): boolean {
return err instanceof AppError && err.tag === "TRANSIENT";
}
func WithRetry(ctx context.Context, fn func() error, maxAttempts int) error {
var err error
for attempt := range maxAttempts {
if err = fn(); err == nil {
return nil
}
if !errors.Is(err, ErrTransient) {
return err
}
if attempt == maxAttempts-1 {
break
}
delay := time.Duration(math.Pow(2, float64(attempt))*500)*time.Millisecond +
time.Duration(rand.Intn(300))*time.Millisecond
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("after %d attempts: %w", maxAttempts, err)
}
Structured API Error Responses
Every API error should include: machine-readable code, human-readable message, optional details for field-level problems.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "reason": "must be a valid email address" },
{ "field": "age", "reason": "must be a positive integer" }
],
"request_id": "req_01HX..."
}
}
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status,
content={
"error": {
"code": exc.code,
"message": str(exc),
"request_id": request.state.request_id,
}
},
)
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const requestId = req.headers["x-request-id"] ?? crypto.randomUUID();
if (err instanceof AppError) {
return res.status(err.status).json({
error: { code: err.code, message: err.message, request_id: requestId },
});
}
console.error("Unhandled error", err);
res.status(500).json({
error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred", request_id: requestId },
});
});
Logging Errors
| Severity | When | Fields to include |
|---|
ERROR | Unhandled or unexpected failures | message, stack, request_id, user_id |
WARN | Handled but notable (retry succeeded, degraded mode) | message, attempt count |
INFO | Expected failures returned to caller (404, 400) | code, path — no stack trace |
DEBUG | Retry attempts, circuit state | attempt, delay, error message |
except SomeError as exc:
logger.error("Failed", exc_info=True)
raise
except SomeError as exc:
raise TransientError("upstream failed") from exc
Red Flags
- Bare
except / catch (e) {} — swallows the error entirely; always re-raise or handle explicitly.
- Retrying non-retryable errors — retrying a 400 or 404 wastes time and can amplify load; check error class before retrying.
raise Exception("something went wrong") — generic base types give callers no way to branch; use typed domain errors.
- Logging and re-raising at every layer — logs the same error multiple times; propagate up and log once at the boundary.
- Using
%v instead of %w in Go — breaks errors.Is / errors.As unwrapping; always use %w when wrapping.
- Ignoring
err in Go — _ = fn() silently discards failures; use //nolint:errcheck only with a documented reason.
- No
request_id in error responses — makes support and debugging impossible; always include a correlation ID.
- Returning 500 for client errors — masks bugs; map domain errors to the correct 4xx before they reach the handler.
- Retry without jitter — synchronized retries hammer a recovering dependency; always add random jitter.
Checklist