Error Handling¶
Error handling is a critical aspect of software development that deals with anticipating, detecting, and resolving errors that occur during program execution. Proper error handling makes software more robust, maintainable, and user-friendly.
Why Error Handling Matters¶
- Reliability: Prevents unexpected crashes and data corruption
- User Experience: Provides meaningful feedback instead of cryptic failures
- Debugging: Makes it easier to identify and fix issues
- Security: Prevents information leakage through unhandled exceptions
- Maintainability: Clear error paths make code easier to understand and modify
Types of Errors¶
1. Syntax Errors¶
Errors detected during parsing/compilation before the program runs. These violate the language's grammar rules.
# Missing colon
if x == 5
print("five")
2. Runtime Errors (Exceptions)¶
Errors that occur during program execution. The code is syntactically correct but encounters an unexpected condition.
# Division by zero
result = 10 / 0
# File not found
file = open("nonexistent.txt")
# Index out of bounds
items = [1, 2, 3]
print(items[10])
3. Logic Errors¶
The program runs without crashing but produces incorrect results. These are the hardest to detect.
# Incorrect calculation (should be width * height)
def calculate_area(width, height):
return width + height # Bug: using + instead of *
Error Handling Strategies¶
1. Fail-Fast¶
Stop execution immediately when an error is detected. This prevents cascading failures and makes debugging easier.
When to use: Development, critical systems where partial results are dangerous.
def process_payment(amount):
if amount <= 0:
raise ValueError("Payment amount must be positive")
# Continue processing...
2. Graceful Degradation¶
Continue operating with reduced functionality when errors occur.
When to use: User-facing applications, non-critical features.
def get_user_avatar(user_id):
try:
return fetch_avatar_from_server(user_id)
except NetworkError:
return DEFAULT_AVATAR # Fallback to default
3. Retry with Backoff¶
Automatically retry failed operations with increasing delays.
When to use: Transient failures (network issues, temporary unavailability).
import time
def fetch_with_retry(url, max_retries=3):
for attempt in range(max_retries):
try:
return fetch(url)
except NetworkError:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt) # Exponential backoff: 1s, 2s, 4s
4. Circuit Breaker¶
Stop calling a failing service temporarily to prevent resource exhaustion and allow recovery.
When to use: Distributed systems, external service calls.
States: CLOSED → OPEN → HALF-OPEN → CLOSED
CLOSED: Normal operation, requests pass through
OPEN: Failures exceeded threshold, requests fail immediately
HALF-OPEN: After timeout, allow limited requests to test recovery
Best Practices¶
1. Catch Specific Exceptions¶
Avoid catching generic exceptions; be specific about what you're handling.
# Bad
try:
process_data()
except Exception:
pass
# Good
try:
process_data()
except ValueError as e:
logger.error(f"Invalid data format: {e}")
except IOError as e:
logger.error(f"File operation failed: {e}")
2. Don't Swallow Exceptions Silently¶
Always log or handle exceptions meaningfully.
# Bad - silent failure
try:
save_to_database(data)
except DatabaseError:
pass
# Good - log and handle
try:
save_to_database(data)
except DatabaseError as e:
logger.error(f"Database save failed: {e}")
notify_admin(e)
raise # Re-raise if caller needs to know
3. Use Finally for Cleanup¶
Ensure resources are released regardless of success or failure.
file = None
try:
file = open("data.txt")
process(file)
except IOError as e:
logger.error(f"File error: {e}")
finally:
if file:
file.close()
4. Provide Context in Error Messages¶
Include relevant information for debugging.
# Bad
raise ValueError("Invalid input")
# Good
raise ValueError(f"Invalid user ID '{user_id}': must be a positive integer")
5. Fail at the Right Level¶
Handle errors at the level where you have enough context to deal with them appropriately.
# Low-level function: raise exception
def parse_config(path):
if not os.path.exists(path):
raise FileNotFoundError(f"Config file not found: {path}")
# ...
# High-level function: handle and provide fallback
def initialize_app():
try:
config = parse_config("config.yaml")
except FileNotFoundError:
logger.warning("Config not found, using defaults")
config = DEFAULT_CONFIG
Python Exception Handling¶
Python uses exceptions as the primary mechanism for error handling. Understanding Python's exception system is essential for writing robust code.
Exception Hierarchy¶
Python's built-in exceptions form a hierarchy. All exceptions inherit from BaseException.
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── StopIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── AssertionError
├── AttributeError
├── BufferError
├── EOFError
├── ImportError
│ └── ModuleNotFoundError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── MemoryError
├── NameError
│ └── UnboundLocalError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── TimeoutError
│ └── ...
├── RuntimeError
│ ├── NotImplementedError
│ └── RecursionError
├── TypeError
├── ValueError
│ └── UnicodeError
└── Warning
├── DeprecationWarning
├── UserWarning
└── ...
Key points:
- Catch Exception to handle most errors (excludes SystemExit, KeyboardInterrupt)
- Catch BaseException only if you need to handle everything (rarely needed)
- More specific exceptions are subclasses of general ones
try/except/else/finally¶
The complete syntax for exception handling in Python:
try:
# Code that might raise an exception
result = risky_operation()
except ValueError as e:
# Handle specific exception
print(f"Value error: {e}")
except (TypeError, KeyError) as e:
# Handle multiple exception types
print(f"Type or key error: {e}")
except Exception as e:
# Catch-all for other exceptions
print(f"Unexpected error: {e}")
raise # Re-raise the exception
else:
# Executed only if no exception occurred
print(f"Success: {result}")
finally:
# Always executed (cleanup)
print("Cleanup complete")
Execution flow:
| Scenario | try | except | else | finally |
|---|---|---|---|---|
| No exception | ✓ | ✗ | ✓ | ✓ |
| Exception caught | ✓ | ✓ | ✗ | ✓ |
| Exception not caught | ✓ | ✗ | ✗ | ✓ |
Raising Exceptions¶
Use raise to throw exceptions explicitly.
# Raise a built-in exception
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
# Raise with cause (exception chaining)
try:
data = json.loads(raw_input)
except json.JSONDecodeError as e:
raise ValueError("Invalid configuration format") from e
# Re-raise current exception
try:
process()
except Exception:
logger.error("Processing failed")
raise # Preserves original traceback
Custom Exceptions¶
Create custom exceptions for domain-specific errors.
# Simple custom exception
class ValidationError(Exception):
"""Raised when data validation fails."""
pass
# Custom exception with additional attributes
class APIError(Exception):
"""Raised when an API call fails."""
def __init__(self, message, status_code=None, response=None):
super().__init__(message)
self.status_code = status_code
self.response = response
def __str__(self):
if self.status_code:
return f"[{self.status_code}] {self.args[0]}"
return self.args[0]
# Exception hierarchy for a module
class DatabaseError(Exception):
"""Base exception for database operations."""
pass
class ConnectionError(DatabaseError):
"""Failed to connect to database."""
pass
class QueryError(DatabaseError):
"""Query execution failed."""
pass
# Usage
try:
connect_to_database()
except ConnectionError as e:
print(f"Connection failed: {e}")
except DatabaseError as e:
print(f"Database error: {e}")
Context Managers (with statement)¶
Context managers ensure proper resource cleanup using __enter__ and __exit__ methods.
# Using built-in context manager
with open("file.txt", "r") as f:
content = f.read()
# File is automatically closed, even if an exception occurs
# Multiple context managers
with open("input.txt") as src, open("output.txt", "w") as dst:
dst.write(src.read())
# Custom context manager using class
class DatabaseConnection:
def __init__(self, connection_string):
self.connection_string = connection_string
self.connection = None
def __enter__(self):
self.connection = connect(self.connection_string)
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connection:
self.connection.close()
# Return True to suppress exception, False to propagate
return False
# Custom context manager using contextlib
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.time()
try:
yield
finally:
elapsed = time.time() - start
print(f"{label}: {elapsed:.3f}s")
# Usage
with timer("Data processing"):
process_large_dataset()
Common Patterns¶
Pattern 1: EAFP vs LBYL¶
EAFP (Easier to Ask Forgiveness than Permission) - Pythonic style:
# EAFP - try and handle exception
try:
value = dictionary[key]
except KeyError:
value = default_value
LBYL (Look Before You Leap) - check first:
# LBYL - check before accessing
if key in dictionary:
value = dictionary[key]
else:
value = default_value
EAFP is preferred in Python when the exceptional case is rare.
Pattern 2: Exception as Control Flow (use sparingly)¶
# Using StopIteration in iterators
class Counter:
def __init__(self, max_count):
self.max_count = max_count
self.count = 0
def __iter__(self):
return self
def __next__(self):
if self.count >= self.max_count:
raise StopIteration
self.count += 1
return self.count
Pattern 3: Null Object / Default Value¶
# Using dict.get() instead of try/except
value = config.get("timeout", 30)
# Using getattr with default
method = getattr(obj, "process", lambda x: x)
Anti-Patterns to Avoid¶
# ❌ Bare except (catches everything including KeyboardInterrupt)
try:
do_something()
except:
pass
# ❌ Catching too broad
try:
value = int(user_input)
except Exception: # Catches more than intended
print("Invalid input")
# ❌ Using exceptions for expected conditions
try:
if items:
process(items[0])
except IndexError:
pass # Should just check if items is non-empty
# ❌ Losing exception context
try:
parse_config()
except ConfigError:
raise RuntimeError("Config failed") # Lost original cause
# ✓ Use: raise RuntimeError("Config failed") from e
# ❌ Returning None to indicate error
def find_user(user_id):
if not valid(user_id):
return None # Caller might forget to check
# ✓ Raise an exception instead
Comparison with Other Languages¶
Java: try-catch-finally¶
try {
FileReader file = new FileReader("file.txt");
// Process file
} catch (FileNotFoundException e) {
System.out.println("File not found: " + e.getMessage());
} catch (IOException e) {
System.out.println("IO error: " + e.getMessage());
} finally {
// Cleanup
}
// Try-with-resources (like Python's with)
try (FileReader file = new FileReader("file.txt")) {
// Process file
}
Key difference: Java has checked exceptions (must be declared or caught).
Go: Error Returns¶
result, err := doSomething()
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
// Use result
Key difference: Errors are values, not exceptions. Explicit error checking required.
Rust: Result Type¶
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("Cannot divide by zero"))
} else {
Ok(a / b)
}
}
// Usage with pattern matching
match divide(10.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
// Or with ? operator for propagation
fn calculate() -> Result<f64, String> {
let result = divide(10.0, 2.0)?;
Ok(result * 2.0)
}
Key difference: Errors must be handled explicitly; compiler enforces error handling.
| Language | Mechanism | Error Handling Style |
|---|---|---|
| Python | Exceptions | EAFP, try/except |
| Java | Exceptions (checked/unchecked) | try-catch, throws |
| Go | Error values | Explicit if err != nil |
| Rust | Result |
Pattern matching, ? operator |