16 — Error Handling
Production Exception Hierarchy — Structured Errors for API Boundaries
# ── A production exception hierarchy for a service layer ──
# Design goals:
# 1. One base exception (ServiceError) for catch-all by callers
# 2. Category exceptions (AuthError, ValidationError) for domain-specific handling
# 3. Specific exceptions carry structured data for programmatic response building
# 4. All exceptions chain from their cause via `raise ... from e`
from typing import Any
class ServiceError(Exception):
"""Base for all service-layer errors. Catch this for 'anything from our code'."""
def __init__(self, message: str, *, code: str = "SERVICE_ERROR"):
super().__init__(message)
self.code = code # machine-readable error code for API responses
class ValidationError(ServiceError):
"""Input validation failure with per-field error details."""
def __init__(self, errors: list[dict[str, str]]):
self.errors = errors # [{"field": "email", "message": "required"}, ...]
message = "; ".join(f"{e['field']}: {e['message']}" for e in errors)
super().__init__(message, code="VALIDATION_ERROR")
class AuthError(ServiceError):
"""Authentication/authorization failure."""
pass
class RateLimitError(ServiceError):
"""Rate limit exceeded — carries retry-after hint."""
def __init__(self, retry_after: float):
self.retry_after = retry_after
super().__init__(f"rate limited, retry after {retry_after}s", code="RATE_LIMITED")
# ── Usage: translating low-level errors into domain-specific ones ──
def parse_user(raw: dict[str, Any]) -> dict:
"""Parse and validate user input — raises ValidationError with field details."""
errors = []
if not raw.get("email"):
errors.append({"field": "email", "message": "required"})
elif "@" not in raw["email"]:
errors.append({"field": "email", "message": "invalid format"})
age = raw.get("age")
if age is None:
errors.append({"field": "age", "message": "required"})
elif not isinstance(age, int):
errors.append({"field": "age", "message": "must be integer"})
elif age < 0 or age > 150:
errors.append({"field": "age", "message": "must be 0-150"})
if errors:
raise ValidationError(errors) # structured data attached, not just a string
return raw
# ── Exception chaining: translate a DB error into a domain error ──
class DatabaseError(ServiceError):
pass
def load_user(user_id: int) -> dict:
try:
# simulate a DB call that might fail
if user_id < 0:
raise KeyError(f"user {user_id} not found")
return {"id": user_id, "name": "Ada"}
except KeyError as e:
# `from e` preserves the original traceback — debuggable, not just "user not found"
raise DatabaseError(f"user {user_id} not found") from e
# ── API layer: converting exceptions to HTTP responses ──
def handle_request(raw_input: dict):
"""Simulated API handler — maps exceptions to structured HTTP-like responses."""
try:
user = parse_user(raw_input)
return {"status": 200, "body": user}
except ValidationError as e:
return {"status": 422, "body": {"code": e.code, "errors": e.errors}}
except ServiceError as e:
return {"status": 500, "body": {"code": e.code, "message": str(e)}}
# Test: validation failure with multiple field errors
result = handle_request({"age": -5})
print(result)
# {'status': 422, 'body': {'code': 'VALIDATION_ERROR',
# 'errors': [{'field': 'email', 'message': 'required'},
# {'field': 'age', 'message': 'must be 0-150'}]}}
Exception Hierarchy and Catching Specifically
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Math error: {e}")
except ArithmeticError as e: # ZeroDivisionError IS-A ArithmeticError — order matters!
print(f"Other arithmetic error: {e}")
except Exception as e: # broad catch-all — catches almost everything else
print(f"Unexpected: {e}")
Python checks except clauses top-to-bottom, using the first one whose type matches (via isinstance, so subclasses match parent-type clauses) — placing a broader exception type before a narrower one makes the narrower clause unreachable dead code, a mistake Python doesn't warn about by default.
# WRONG — the ArithmeticError clause makes ZeroDivisionError's clause unreachable
try:
1 / 0
except ArithmeticError:
print("caught as ArithmeticError") # this always wins; ZeroDivisionError never gets a chance
except ZeroDivisionError:
print("caught as ZeroDivisionError") # DEAD CODE — never runs
The exception hierarchy that matters day-to-day
# BaseException
# +-- SystemExit, KeyboardInterrupt, GeneratorExit (deliberately NOT caught by `except Exception`)
# +-- Exception
# +-- ArithmeticError -> ZeroDivisionError, OverflowError
# +-- LookupError -> IndexError, KeyError
# +-- ValueError
# +-- TypeError
# +-- OSError -> FileNotFoundError, PermissionError, ConnectionError, TimeoutError
# +-- RuntimeError -> RecursionError, NotImplementedError
except Exception deliberately does not catch SystemExit or KeyboardInterrupt (both inherit directly from BaseException, not Exception) — this is intentional, so that sys.exit() and Ctrl+C reliably terminate a program even inside broad exception-handling code, unless a handler explicitly asks to catch BaseException.
Never Use a Bare except:
# DANGEROUS — catches EVERYTHING, including KeyboardInterrupt, SystemExit, and typos manifesting as NameError
def risky():
try:
return complicated_calculation() # NameError: name not defined — a genuine BUG
except:
return None # silently hides the bug, returns None instead
# BETTER — catch only what you can actually handle meaningfully
def risky_fixed():
try:
return complicated_calculation()
except (ValueError, ZeroDivisionError) as e:
log.warning(f"Calculation failed: {e}")
return None
A bare except: (or the nearly-as-broad except Exception: used indiscriminately) turns programming errors — typos, missing imports, wrong argument counts — into silent None returns instead of loud tracebacks, which is one of the most common sources of "it fails silently in production but works in my tests" bugs.
Custom Exceptions
class AppError(Exception):
"""Base exception for all application-specific errors."""
class InsufficientFundsError(AppError):
def __init__(self, balance, requested):
self.balance = balance
self.requested = requested
super().__init__(f"Cannot withdraw {requested}: balance is only {balance}")
class AccountFrozenError(AppError):
pass
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
withdraw(100, 250)
except InsufficientFundsError as e:
print(e) # Cannot withdraw 250: balance is only 100
print(e.balance, e.requested) # 100 250 — custom attributes for programmatic handling
except AppError:
print("Some other app error")
Best practice: define one base exception per application/library (AppError) and derive specific exceptions from it — callers can catch broadly (except AppError) when they just want "anything my own code raised" or narrowly (except InsufficientFundsError) when they need to branch on the specific failure, and third-party exceptions never get accidentally conflated with your own.
raise ... from — Exception Chaining
def load_user_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError as e:
raise AppError(f"Could not load config from {path}") from e # preserves the ORIGINAL cause
try:
load_user_config("/nonexistent/config.json")
except AppError as e:
print(e) # Could not load config from /nonexistent/config.json
print(e.__cause__) # [Errno 2] No such file or directory: '/nonexistent/config.json'
Without from e, the traceback still shows both exceptions (Python automatically records the original as __context__ during exception handling), but labeled as "During handling of the above exception, another exception occurred" — implying an accidental, unrelated failure. raise ... from e instead marks it explicitly as "The above exception was the direct cause," which is the more accurate and more debuggable story when you're deliberately translating one exception type into another.
Suppressing the chain entirely
try:
int("not a number")
except ValueError:
raise RuntimeError("Config value must be numeric") from None # hides the ValueError entirely
# Traceback shows ONLY the RuntimeError — appropriate when the original exception
# is pure noise for the caller (e.g., an internal implementation detail)
Custom Exceptions with Structured Data
class ValidationError(Exception):
def __init__(self, errors):
self.errors = errors # a list of field-level error dicts
message = f"{len(errors)} validation error(s): " + ", ".join(e["field"] for e in errors)
super().__init__(message)
def validate_user(data):
errors = []
if not data.get("email"):
errors.append({"field": "email", "message": "required"})
if data.get("age", 0) < 0:
errors.append({"field": "age", "message": "must be non-negative"})
if errors:
raise ValidationError(errors)
return data
try:
validate_user({"age": -5})
except ValidationError as e:
print(e) # 2 validation error(s): email, age
for err in e.errors:
print(f" {err['field']}: {err['message']}")
Attaching structured data (not just a message string) to custom exceptions lets calling code programmatically react to what went wrong — an API layer can turn e.errors directly into a JSON error response, something impossible if the exception only carries a human-readable string.
Context Managers for Cleanup: try/finally vs with
# Manual try/finally — correct, but verbose and easy to get wrong with multiple resources
lock = threading.Lock()
lock.acquire()
try:
do_work()
finally:
lock.release()
# Idiomatic — the context manager protocol (chapter 14/19) guarantees the same cleanup, less code
with threading.Lock():
do_work()
Any cleanup that must happen "no matter what" — closing files, releasing locks, rolling back transactions, disconnecting sockets — is a candidate for with if the object supports it, and a candidate for try/finally if it doesn't. finally and __exit__ share the exact same guarantee: they run even if the protected code returns early, breaks out of a loop, or raises.
Multiple except Types and Exception Groups
try:
value = int(input_str) / divisor
except (ValueError, ZeroDivisionError) as e: # one clause, multiple types — tuple syntax
print(f"Invalid input: {e}")
# Python 3.11+ — ExceptionGroup and except* for handling multiple concurrent failures
try:
raise ExceptionGroup("multiple failures", [
ValueError("bad value"),
TypeError("bad type"),
])
except* ValueError as eg:
print(f"Handled ValueErrors: {eg.exceptions}")
except* TypeError as eg:
print(f"Handled TypeErrors: {eg.exceptions}")
ExceptionGroup/except* (3.11+) exist because some operations — notably asyncio.TaskGroup (chapter 22) — can genuinely fail with multiple independent exceptions at once, which the traditional single-exception model has no way to represent; except* can match and handle each sub-exception type from the group independently.
assert Is Not Error Handling
def set_discount(percent):
assert 0 <= percent <= 100, "percent must be between 0 and 100"
return percent / 100
# python -O my_script.py # runs with optimizations ON — assert statements are STRIPPED ENTIRELY
assert statements are removed completely when Python runs with the -O (optimize) flag — meaning assert-based validation silently vanishes in that mode. Never use assert for input validation, security checks, or anything that must always run — use it only for internal invariants/debugging aids that are fine to disable in production, and raise real exceptions (ValueError, a custom exception) for anything that represents genuinely invalid input.
💡 Tips & Tricks
- Idiom: catch the narrowest exception type that lets you handle the failure meaningfully —
except Exception(or worse, bareexcept:) as a habit turns real bugs into silent failures indistinguishable from expected error conditions. - Debug:
traceback.print_exc()orlogging.exception(...)inside anexceptblock preserves the full traceback for debugging, unlikeprint(e), which shows only the exception's string message and loses the call stack. - Idiom: use
raise ... from ewhen deliberately translating a low-level exception into a higher-level, more meaningful one — it keeps the original cause visible in the traceback instead of looking like an unrelated second failure. - Idiom: put the "happy path" continuation in
else, not at the end oftry— this keeps thetryblock scoped tightly to just the line(s) that can actually raise, so you don't accidentally catch an exception from code that was never meant to be protected. - Debug:
sys.exc_info()ande.__traceback__give you programmatic access to the current exception's traceback object — useful for custom logging/reporting frameworks that need to format or forward exception details.
⚠️ Edge Cases & Gotchas
- A bare
except:(or carelessexcept Exception:) silently swallowsNameError,TypeError, and other bugs alongside the errors you meant to handle — this is the single most common way real programming mistakes get hidden until they cause confusing behavior far from the actual bug. exceptclause order matters — a broader exception type listed before a narrower (subclass) one makes the narrower clause permanently unreachable, with no warning from the interpreter; always orderexceptclauses from most specific to most general.assertstatements are completely stripped when Python runs with-O/-OO— code that relies onassertfor validation that must always happen (input sanitization, security checks) has a silent, environment-dependent bug: it works in development, and stops validating anything the moment-Ois used.except Exceptiondoes not catchKeyboardInterruptorSystemExit, because both inherit fromBaseExceptiondirectly, notException— this is usually desirable (Ctrl+C should still work inside a broadtry), but code that genuinely needs to catch everything, including these, must explicitly writeexcept BaseException, and should almost always re-raise after cleanup rather than suppress them.- A
return,break, orcontinueinside atryblock does NOT skip thefinallyclause —finallystill runs before control actually leaves the block, and areturninsidefinallywill silently override (discard) areturnor an in-flight exception from thetry/except— a rare but genuinely confusing gotcha worth avoiding by neverreturn-ing fromfinally.
🧠 Spot the Bug
A retry helper is supposed to retry a flaky operation up to 3 times, but it swallows a critical bug that should have crashed the program immediately. Find the issue.
def fetch_with_retry(fetch_fn, attempts=3):
for attempt in range(attempts):
try:
return fetch_fn()
except:
print(f"Attempt {attempt + 1} failed, retrying...")
raise RuntimeError("All attempts failed")
def fetch_data():
respones = {"status": "ok"} # typo: should be `response`
return respones["status"]
fetch_with_retry(fetch_data)
Answer
fetch_data actually has no bug in the shown snippet — but suppose the real function had a genuine typo like response["staus"] (a KeyError from a misspelled key, i.e., a real programming mistake, not a transient failure). The bare except: in fetch_with_retry catches that exactly the same way it catches a legitimate transient failure (like a ConnectionError) — it retries three times, fails three times for the exact same reason (the typo doesn't go away on retry), and then raises a generic RuntimeError("All attempts failed") that completely hides the real KeyError/NameError and its traceback.
The fix is to only catch the specific, genuinely-transient exception types that retrying could plausibly fix, and let programming errors propagate immediately:
def fetch_with_retry(fetch_fn, attempts=3):
for attempt in range(attempts):
try:
return fetch_fn()
except (ConnectionError, TimeoutError) as e:
print(f"Attempt {attempt + 1} failed: {e}, retrying...")
raise RuntimeError("All attempts failed")
Now a KeyError/NameError/TypeError from a real bug propagates immediately on the first attempt, with its original traceback intact, instead of being disguised as a generic "all attempts failed" after three pointless retries.
The lesson: a bare (or overly broad) except inside a retry loop is especially dangerous — it doesn't just hide one bug, it wastes time re-attempting a failure that retrying can never fix, then buries the real error under a misleading top-level message.
Key Takeaways
try/except/else/finallyeach have distinct roles:elseruns only on success (keeping it separate from error handling),finallyalways runs, even throughreturn/break/an uncaught exception.- Never use a bare
except:— it catches typos and programming errors (NameError,TypeError) alongside real, expected failure modes, hiding bugs as silentNonereturns or generic messages. - Order
exceptclauses from most specific to most general — a broad type listed first makes narrower clauses below it permanently unreachable, with no warning. - Define a base custom exception per application/library and derive specific exceptions from it, optionally carrying structured data (not just a message) so callers can react programmatically.
- Use
raise ... from ewhen deliberately translating one exception into another — it preserves the original cause in the traceback instead of implying an unrelated, accidental second failure. assertis stripped entirely underpython -O— never use it for validation that must always run; raise real exceptions for anything representing invalid input or state.