Error handling is not defensive programming bolted on at the end — it is a core design decision that shapes how observable, debuggable, and reliable a service is. Systems that handle errors well fail quickly on bad inputs, surface actionable information to operators, return useful responses to clients, and recover automatically where possible. Systems that handle errors poorly fail silently, produce corrupted state, and require hours of log archaeology to diagnose. Here is how to get it right in 2026.
What changed in 2026
- Structured logging became the baseline — JSON log lines with correlation IDs, service names, and severity levels are expected in any production service. Plain
print() error messages are a flag in code review.
- OpenTelemetry v2 standardized error spans — errors automatically propagate trace context so distributed errors are linked across service boundaries.
- Result types in TypeScript (via
neverthrow, effect, or manual discriminated unions) became a popular pattern for making error paths explicit in the type system.
- AI-assisted root cause analysis in observability tools (Datadog, Grafana) can surface likely error causes from stack traces, but they still need good log context to work.
Error classification
Operational errors
Expected failure conditions — wrong input, network timeout, resource not found, permission denied. These are part of normal system operation and should be handled gracefully.
class NotFoundError(Exception):
"""Resource does not exist — expected, return 404."""
pass
class ValidationError(Exception):
"""Invalid input — expected, return 422."""
def __init__(self, field: str, message: str):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
Programmer errors
Bugs — null pointer dereferences, assertion failures, type errors, unhandled branches. These should crash loudly (or be caught at the top level and alerted on) — never silently swallowed.
The rule: operational errors → handle, recover, respond. Programmer errors → let them surface, alert, fix.
Structured error responses
Every API should return machine-readable errors:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request parameters",
"details": [
{"field": "email", "message": "must be a valid email address"},
{"field": "amount", "message": "must be greater than 0"}
],
"request_id": "req_01HX2J3K4M"
}
}
A string message alone is not enough for client-side handling. The code field is what the client switches on; the message is for humans.
HTTP status codes for errors
| Scenario |
Status code |
| Invalid input / validation failure |
422 Unprocessable Entity |
| Resource not found |
404 Not Found |
| Not authenticated |
401 Unauthorized |
| Authenticated but not permitted |
403 Forbidden |
| Conflict (duplicate, stale update) |
409 Conflict |
| Rate limited |
429 Too Many Requests |
| Internal bug / unhandled error |
500 Internal Server Error |
| Downstream service unavailable |
503 Service Unavailable |
Use 500 only for truly unexpected errors. If you know what went wrong, use the specific code.
Error propagation: catch at the right level
# Wrong: catching at the wrong level, losing context
def get_user(user_id: int):
try:
return db.query(User).filter_by(id=user_id).first()
except Exception:
return None # Silently swallows DB connection errors, auth errors, anything
# Right: let specific, expected exceptions propagate; catch at the handler level
def get_user(user_id: int) -> User:
user = db.query(User).filter_by(id=user_id).first()
if user is None:
raise NotFoundError(f"User {user_id} not found")
return user
# FastAPI handler — catches and converts to HTTP response
@app.get("/users/{user_id}")
async def read_user(user_id: int):
try:
return get_user(user_id)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
The data access layer raises domain errors; the HTTP layer translates them to responses. Each layer handles what it can; the rest propagates up.
Logging errors with context
import logging, structlog
log = structlog.get_logger()
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
log.error(
"unhandled_exception",
path=request.url.path,
method=request.method,
request_id=request.headers.get("X-Request-Id"),
exc_info=True, # includes stack trace
)
return JSONResponse(
status_code=500,
content={"error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}},
)
Never log the raw exception message alone. Log the request context, user ID (not PII), and enough state to reproduce the failure.
TypeScript: making errors explicit
// Discriminated union result type
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAmount(input: string): Result<number, string> {
const n = Number(input);
if (isNaN(n) || n <= 0) {
return { ok: false, error: `"${input}" is not a valid positive number` };
}
return { ok: true, value: n };
}
const result = parseAmount(userInput);
if (!result.ok) {
// TypeScript knows result.error is a string here
return res.status(422).json({ error: result.error });
}
// TypeScript knows result.value is a number here
processPayment(result.value);
Making the error path a first-class return value (rather than an exception) forces calling code to handle it — the type system enforces it.
Retry and circuit breaker patterns
For transient operational errors (network timeout, 503):
import tenacity
@tenacity.retry(
stop=tenacity.stop_after_attempt(3),
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
retry=tenacity.retry_if_exception_type(ConnectionError),
reraise=True,
)
def call_payment_api(payload: dict):
return requests.post("https://pay.example.com/charge", json=payload, timeout=5)
Retry only on transient, idempotent operations. Do not retry on 4xx errors (the client is wrong — retrying will not help).
How to pick your error handling strategy
- Input validation? Fail fast at the boundary — validate before doing any work and return 422 with field-level details.
- External service call? Retry with exponential backoff on transient errors; circuit-break after repeated failures.
- Business rule violation? Raise a named domain exception; let the HTTP layer translate it to the correct status code.
- Unhandled exceptions? Top-level handler logs everything with full context, returns a generic 500, and sends an alert.
- Async background jobs? Catch all exceptions, log them, and decide whether to retry or move to a dead-letter queue.
Common mistakes
Empty catch blocks. except Exception: pass is the single most damaging pattern in production code. At minimum, log. Usually, re-raise.
Using exceptions for flow control. Raising a StopIteration or BreakException to exit a loop is clever and unreadable. Use break, return, or a Result type.
Leaking internal details in error responses. Stack traces, SQL queries, and internal file paths in 500 responses are a security leak. Show generic messages to clients; log details internally.
Not including correlation IDs. An error without a request ID makes it nearly impossible to correlate the client error report with the server log. Generate a UUID per request and propagate it.
Catching broad exceptions and converting to narrow ones. Wrapping every exception in InternalError loses the original error type and makes debugging harder. Preserve the original exception as a cause.
What to skip
- Silent error swallowing anywhere in the call stack — this is the root cause of phantom bugs.
- Custom exception hierarchies that mirror the HTTP status code tree — too much overhead; a small set of domain exceptions mapped at the HTTP layer is sufficient.
- Logging every caught exception at ERROR level — use DEBUG/INFO for expected operational errors (404, validation); reserve ERROR/CRITICAL for unexpected failures.
FAQ
Should I use exceptions or error codes?
Both have their place. Exceptions are idiomatic for Python and Java; discriminated union result types are increasingly common in TypeScript. The key is that error paths are explicit and not silently dropped.
How detailed should error messages be?
As detailed as useful for the caller, without leaking internals. Field-level validation errors are helpful. Stack traces in responses are not.
When should I retry vs fail immediately?
Retry transient errors (network timeouts, 503s) with backoff. Fail immediately on 4xx errors (bad input, unauthorized) — retrying will not change the outcome.
What is a dead-letter queue?
A queue where messages or jobs go after exhausting all retries. It allows manual inspection and replay after fixing the root cause, rather than losing the work entirely.
Where to go next