Error Handling
Intro
Errors are part of the type system, not an afterthought. Make
failure paths explicit, separate user-facing messages from internal
diagnostics, and reach for retries and circuit breakers only when
the failure is genuinely transient.
Overview
Result types vs exceptions
Use algebraic types where the language supports them — they make
error paths explicit in the signature.
- Rust:
Result<T, E> for recoverable errors, Option<T> for
missing values. Propagate with ?. Define domain error enums
with thiserror.
- TypeScript: discriminated unions or a
Result<T, E> library.
Avoid throw for expected failures (validation, not-found).
Reserve exceptions for truly unexpected conditions.
- Python: exceptions are idiomatic, but consider returning
None or a result tuple in tight loops where try/except overhead
matters.
- Java: checked exceptions for recoverable conditions the
caller must handle, unchecked (
RuntimeException) for
programming errors. Prefer specific types over Exception.
Error hierarchies
Design errors in layers:
- Domain errors — business rule violations
(
InsufficientFunds, ItemOutOfStock).
- Application errors — workflow failures (
OrderNotFound,
Unauthorized).
- Infrastructure errors — external system failures
(
DatabaseUnavailable, TimeoutError).
Each layer exposes only errors meaningful at that level.
Infrastructure errors are mapped to application errors before
reaching the domain.
User-facing vs internal
Never leak stack traces, SQL, or file paths to end users.
- Internal errors: full context for debugging — stack trace,
request ID, input values.
- User-facing errors: human-readable message, error code,
optional field-level details.
- Map at the boundary (HTTP handler, CLI output). Log the internal
error with a correlation ID; return the ID to the user for
support.
Error codes and structured responses
Use namespaced, stable error codes (PAYMENT.DECLINED,
AUTH.TOKEN_EXPIRED, VALIDATION.FIELD_REQUIRED). Document them
in a central registry. Never reuse or rename codes once shipped.
For APIs, return a consistent error shape:
{
"error": {
"code": "VALIDATION.FIELD_REQUIRED",
"message": "The 'email' field is required.",
"details": [
{ "field": "email", "reason": "required" }
],
"request_id": "req-abc123"
}
}
Retries and circuit breakers
Retry only transient failures (network timeouts, 503s, lock
contention). Use exponential backoff with jitter: base delay
100–500ms, multiply by 2 each attempt, add random jitter (0–100% of
delay) to prevent thundering herd. Cap retries (3–5) and total
timeout. Never retry 4xx client errors (except 429), validation
failures, or auth errors.
Circuit breakers prevent cascading failures when a downstream
service is unhealthy:
- Closed (normal): requests flow through; track failure rate.
- Open (tripped): requests fail immediately; return a fallback.
- Half-open (probing): allow one request after a timeout. On
success, close. On failure, reopen.
Configure failure threshold (e.g. 5 failures in 30s), open duration
(e.g. 60s), and half-open probe count.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Catching
Exception (or bare except:) and swallowing it. Silent error swallowing is the most common cause of production mysteries where "nothing went wrong, but nothing worked either." Catch specific error types; log and re-raise everything else.
- Leaking stack traces, SQL, or file paths in HTTP responses. Internal error details in user-facing responses are both a security vulnerability and a poor user experience. Map internal errors to user-facing codes at the boundary; log the full context internally with a correlation ID.
- Retrying non-idempotent operations without a deduplication key. Retrying a payment, an email send, or an inventory decrement without idempotency protection causes double-charges and double-sends. Add an idempotency key at the call site before wiring retry logic.
- Retrying 4xx errors as if they were transient. A 400 (bad request) or 401 (unauthorized) will not change on retry — it reflects a problem with the request, not the server. Retry only transient failures: 429, 500, 502, 503, 504, and network-level errors.
- Retrying without exponential backoff and jitter. Fixed-interval retries on a struggling service create a thundering herd that prevents recovery. Use exponential backoff with random jitter; cap the total number of retries and the total elapsed time.
- Sentinel return values instead of explicit errors. Returning
-1, null, or an empty string to signal failure requires every caller to know and check the convention. Make failure explicit in the type: Result<T, E>, Option<T>, or a domain error type.
- Renaming or reusing error codes after shipping. Error codes are a public contract. Consumers build conditional logic on them. Renaming
PAYMENT.DECLINED or reusing a retired code silently breaks consumer error handling.
Full reference
Error reporting
- Log with structured fields:
level, error_code, message,
stack, request_id, user_id.
- Send unhandled exceptions to an error tracker (Sentry, Datadog,
Honeybadger).
- Alert on error rate spikes, not individual errors.
- Group errors by root cause, not by message string.
Domain error enum (Rust)
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PaymentError {
#[error("insufficient funds: required {required}, available {available}")]
InsufficientFunds { required: u64, available: u64 },
#[error("card declined: {reason}")]
CardDeclined { reason: String },
#[error("payment provider unavailable")]
ProviderUnavailable(#[source] reqwest::Error),
}
pub fn charge(amount: u64, balance: u64) -> Result<Receipt, PaymentError> {
if amount > balance {
return Err(PaymentError::InsufficientFunds {
required: amount,
available: balance,
});
}
Ok(Receipt { amount })
}
Retry with backoff (Python)
import time
import random
import httpx
def fetch_with_retry(url: str, max_retries: int = 4) -> httpx.Response:
base_delay = 0.3
for attempt in range(max_retries + 1):
try:
resp = httpx.get(url, timeout=5.0)
if resp.status_code == 429:
retry_after = float(resp.headers.get("Retry-After", base_delay))
time.sleep(retry_after + random.uniform(0, 0.5))
continue
resp.raise_for_status()
return resp
except httpx.TransportError:
if attempt == max_retries:
raise
delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay)
time.sleep(delay)
raise httpx.HTTPError(f"Failed after {max_retries} retries")
Structured API error response (TypeScript)
class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 500,
public details?: { field: string; reason: string }[],
) {
super(message);
}
}
// In route handler
app.post("/orders", async (req, res) => {
try {
const order = await createOrder(req.body);
res.status(201).json(order);
} catch (err) {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
request_id: req.id,
},
});
} else {
console.error("Unhandled error", { request_id: req.id, err });
res.status(500).json({
error: {
code: "INTERNAL.UNEXPECTED",
message: "An unexpected error occurred.",
request_id: req.id,
},
});
}
}
});
Anti-patterns
- Catching
Exception (or bare except:) and swallowing it
- Returning sentinel values (
-1, empty string) instead of an
explicit error
- Leaking SQL, stack traces, or filesystem paths in HTTP responses
- Retrying non-idempotent operations without a deduplication key
- Retrying 4xx errors as if they were transient
- Renaming or reusing error codes after shipping them
- Alerting on every individual error instead of rate spikes
1---2name: error-handling3description: Error handling across languages — Result types, exceptions, retries, circuit breakers, and structured error responses. Use when designing error handling for a module or service, choosing between Result types and exceptions, building error hierarchies, implementing retries or circuit breakers, or reviewing code for missing or poor error handling.4---56# Error Handling78## Intro910Errors are part of the type system, not an afterthought. Make11failure paths explicit, separate user-facing messages from internal12diagnostics, and reach for retries and circuit breakers only when13the failure is genuinely transient.1415## Overview1617### Result types vs exceptions1819Use algebraic types where the language supports them — they make20error paths explicit in the signature.2122- **Rust:** `Result<T, E>` for recoverable errors, `Option<T>` for23 missing values. Propagate with `?`. Define domain error enums24 with `thiserror`.25- **TypeScript:** discriminated unions or a `Result<T, E>` library.26 Avoid `throw` for expected failures (validation, not-found).27 Reserve exceptions for truly unexpected conditions.28- **Python:** exceptions are idiomatic, but consider returning29 `None` or a result tuple in tight loops where try/except overhead30 matters.31- **Java:** checked exceptions for recoverable conditions the32 caller must handle, unchecked (`RuntimeException`) for33 programming errors. Prefer specific types over `Exception`.3435### Error hierarchies3637Design errors in layers:38391. **Domain errors** — business rule violations40 (`InsufficientFunds`, `ItemOutOfStock`).412. **Application errors** — workflow failures (`OrderNotFound`,42 `Unauthorized`).433. **Infrastructure errors** — external system failures44 (`DatabaseUnavailable`, `TimeoutError`).4546Each layer exposes only errors meaningful at that level.47Infrastructure errors are mapped to application errors before48reaching the domain.4950### User-facing vs internal5152Never leak stack traces, SQL, or file paths to end users.5354- **Internal errors:** full context for debugging — stack trace,55 request ID, input values.56- **User-facing errors:** human-readable message, error code,57 optional field-level details.58- Map at the boundary (HTTP handler, CLI output). Log the internal59 error with a correlation ID; return the ID to the user for60 support.6162### Error codes and structured responses6364Use namespaced, stable error codes (`PAYMENT.DECLINED`,65`AUTH.TOKEN_EXPIRED`, `VALIDATION.FIELD_REQUIRED`). Document them66in a central registry. Never reuse or rename codes once shipped.6768For APIs, return a consistent error shape:6970```json71{72 "error": {73 "code": "VALIDATION.FIELD_REQUIRED",74 "message": "The 'email' field is required.",75 "details": [76 { "field": "email", "reason": "required" }77 ],78 "request_id": "req-abc123"79 }80}81```8283### Retries and circuit breakers8485Retry only transient failures (network timeouts, 503s, lock86contention). Use exponential backoff with jitter: base delay87100–500ms, multiply by 2 each attempt, add random jitter (0–100% of88delay) to prevent thundering herd. Cap retries (3–5) and total89timeout. **Never retry** 4xx client errors (except 429), validation90failures, or auth errors.9192Circuit breakers prevent cascading failures when a downstream93service is unhealthy:9495- **Closed** (normal): requests flow through; track failure rate.96- **Open** (tripped): requests fail immediately; return a fallback.97- **Half-open** (probing): allow one request after a timeout. On98 success, close. On failure, reopen.99100Configure failure threshold (e.g. 5 failures in 30s), open duration101(e.g. 60s), and half-open probe count.102103## Gotchas104105Agent-specific failure modes — provider-neutral pause-and-self-check items:106107- **Catching `Exception` (or bare `except:`) and swallowing it.** Silent error swallowing is the most common cause of production mysteries where "nothing went wrong, but nothing worked either." Catch specific error types; log and re-raise everything else.108- **Leaking stack traces, SQL, or file paths in HTTP responses.** Internal error details in user-facing responses are both a security vulnerability and a poor user experience. Map internal errors to user-facing codes at the boundary; log the full context internally with a correlation ID.109- **Retrying non-idempotent operations without a deduplication key.** Retrying a payment, an email send, or an inventory decrement without idempotency protection causes double-charges and double-sends. Add an idempotency key at the call site before wiring retry logic.110- **Retrying 4xx errors as if they were transient.** A 400 (bad request) or 401 (unauthorized) will not change on retry — it reflects a problem with the request, not the server. Retry only transient failures: 429, 500, 502, 503, 504, and network-level errors.111- **Retrying without exponential backoff and jitter.** Fixed-interval retries on a struggling service create a thundering herd that prevents recovery. Use exponential backoff with random jitter; cap the total number of retries and the total elapsed time.112- **Sentinel return values instead of explicit errors.** Returning `-1`, `null`, or an empty string to signal failure requires every caller to know and check the convention. Make failure explicit in the type: `Result<T, E>`, `Option<T>`, or a domain error type.113- **Renaming or reusing error codes after shipping.** Error codes are a public contract. Consumers build conditional logic on them. Renaming `PAYMENT.DECLINED` or reusing a retired code silently breaks consumer error handling.114115## Full reference116117### Error reporting118119- Log with structured fields: `level`, `error_code`, `message`,120 `stack`, `request_id`, `user_id`.121- Send unhandled exceptions to an error tracker (Sentry, Datadog,122 Honeybadger).123- Alert on error rate spikes, not individual errors.124- Group errors by root cause, not by message string.125126### Domain error enum (Rust)127128```rust129use thiserror::Error;130131#[derive(Error, Debug)]132pub enum PaymentError {133 #[error("insufficient funds: required {required}, available {available}")]134 InsufficientFunds { required: u64, available: u64 },135136 #[error("card declined: {reason}")]137 CardDeclined { reason: String },138139 #[error("payment provider unavailable")]140 ProviderUnavailable(#[source] reqwest::Error),141}142143pub fn charge(amount: u64, balance: u64) -> Result<Receipt, PaymentError> {144 if amount > balance {145 return Err(PaymentError::InsufficientFunds {146 required: amount,147 available: balance,148 });149 }150 Ok(Receipt { amount })151}152```153154### Retry with backoff (Python)155156```python157import time158import random159import httpx160161def fetch_with_retry(url: str, max_retries: int = 4) -> httpx.Response:162 base_delay = 0.3163 for attempt in range(max_retries + 1):164 try:165 resp = httpx.get(url, timeout=5.0)166 if resp.status_code == 429:167 retry_after = float(resp.headers.get("Retry-After", base_delay))168 time.sleep(retry_after + random.uniform(0, 0.5))169 continue170 resp.raise_for_status()171 return resp172 except httpx.TransportError:173 if attempt == max_retries:174 raise175 delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay)176 time.sleep(delay)177 raise httpx.HTTPError(f"Failed after {max_retries} retries")178```179180### Structured API error response (TypeScript)181182```typescript183class AppError extends Error {184 constructor(185 public code: string,186 message: string,187 public statusCode: number = 500,188 public details?: { field: string; reason: string }[],189 ) {190 super(message);191 }192}193194// In route handler195app.post("/orders", async (req, res) => {196 try {197 const order = await createOrder(req.body);198 res.status(201).json(order);199 } catch (err) {200 if (err instanceof AppError) {201 res.status(err.statusCode).json({202 error: {203 code: err.code,204 message: err.message,205 details: err.details,206 request_id: req.id,207 },208 });209 } else {210 console.error("Unhandled error", { request_id: req.id, err });211 res.status(500).json({212 error: {213 code: "INTERNAL.UNEXPECTED",214 message: "An unexpected error occurred.",215 request_id: req.id,216 },217 });218 }219 }220});221```222223### Anti-patterns224225- Catching `Exception` (or bare `except:`) and swallowing it226- Returning sentinel values (`-1`, empty string) instead of an227 explicit error228- Leaking SQL, stack traces, or filesystem paths in HTTP responses229- Retrying non-idempotent operations without a deduplication key230- Retrying 4xx errors as if they were transient231- Renaming or reusing error codes after shipping them232- Alerting on every individual error instead of rate spikes