- Author: Amin Boulouma, Software Engineer
- Github source code: https://github.com/aminblm/ai_systems_design_from_scratch
- Engineering Blog: https://aminblm.github.io/ai_systems_design_from_scratch/blog/
The Senior Engineer’s Guide to Robust Exception Handling
In production environments, the most dangerous code is code that fails silently. A common trap for junior engineers is the “bare except” clause, which catches every possible error—including system interrupts—and buries them. This leads to “zombie” services that are running but producing incorrect data or deadlocked states. Exception handling is not about hiding errors; it is about providing clear, actionable signals to the system to recover gracefully or fail informatively.
Glossary for 5-Year-Olds
- Exception: A “uh-oh” moment when the code hits a problem it doesn’t know how to handle.
- Catching: Safely grabbing the error so the whole computer program doesn’t crash and stop working.
- Silently Swallowing: When you hide the error so no one knows it happened—this is bad because you can’t fix what you can’t see!
- Graceful Failure: When the program realizes it can’t finish the job, so it cleans up its mess and tells you what went wrong instead of just disappearing.
The Problem: The “Bare Except” Anti-Pattern
When you write except: pass, you are effectively turning off the alarm system for your house. If a database connection fails, a keyboard interrupt occurs, or a memory error triggers, your code will ignore it and keep going as if everything is fine.
We choose specific exception catching because it adheres to the Principle of Least Astonishment. We only want to handle the errors we expect and know how to recover from. Everything else should be allowed to propagate up to a global error handler that alerts the engineering team.
Simple Example: Targeted Recovery
Instead of catching everything, we target the specific error we know how to fix.
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
# We know how to handle division by zero
return float('inf')
# Usage
print(divide(10, 0)) # Output: inf
Complex Example: Enterprise-Grade Error Handling
In a production service, we often need to log the error, potentially retry, and then raise a custom exception that provides more context to the caller.
class PaymentError(Exception):
"""Custom exception for payment-specific failures."""
pass
class PaymentGateway:
def process(self, amount):
try:
# Simulate a network failure
raise ConnectionError("Gateway unreachable")
except ConnectionError as e:
# 1. Log the error for observability
print(f"Logging error to telemetry: {e}")
# 2. Wrap and re-raise with business context
raise PaymentError("Transaction failed due to network") from e
# Usage
gateway = PaymentGateway()
try:
gateway.process(100)
except PaymentError as e:
print(f"User-facing error: {e}")
Quick Reference: Handling Strategies
| Strategy | When to use | Impact |
|---|---|---|
| Catch Specific | Known, recoverable errors | High resiliency |
| Catch-All (Global) | Unhandled critical failures | Prevents system crash |
Reraise (from e) |
Adding context to errors | Excellent observability |
Developer Checklist
- Specific Exceptions: Am I catching
ExceptionorBaseException? (Avoid this!) - Cleanup: Am I using
finallyorcontextlibto ensure resources (like files/sockets) are closed? - Telemetry: Is the error being logged with sufficient context (stack trace, input arguments)?
- Re-raising: If I catch an error I cannot resolve, am I letting it propagate so the parent can handle it?
TL;DR Summary
Exception handling is the art of defining failure boundaries. Never catch an exception you aren’t prepared to handle. Use specific blocks, log the context, and always use raise from to maintain the exception chain. Clear error signals make for stable systems; hiding them makes for debugging nightmares.