Exception Handling Advanced
Exception handling is crucial for writing robust Python applications. While basic try/except blocks are essential, advanced techniques like custom exceptions, exception chaining, and exception groups make your error handling more precise and informative.
Custom Exception Classes
Section titled “Custom Exception Classes”Basic Custom Exceptions
Section titled “Basic Custom Exceptions”Creating custom exceptions helps make your code more readable and allows for specific error handling:
class ValidationError(Exception): """Base exception for validation errors""" pass
class InvalidEmailError(ValidationError): """Raised when email format is invalid""" pass
class InvalidAgeError(ValidationError): """Raised when age is invalid""" pass
def validate_user(email: str, age: int): """Validate user data""" if "@" not in email: raise InvalidEmailError(f"Invalid email format: {email}")
if age < 0 or age > 150: raise InvalidAgeError(f"Invalid age: {age}")
# Usagetry: validate_user("invalid-email", 25)except InvalidEmailError as e: print(f"Email error: {e}")except InvalidAgeError as e: print(f"Age error: {e}")except ValidationError as e: print(f"Validation error: {e}") # Catches any ValidationErrorCustom Exceptions with Additional Data
Section titled “Custom Exceptions with Additional Data”Add context to your exceptions:
class PaymentError(Exception): """Base exception for payment errors""" def __init__(self, message: str, amount: float, transaction_id: str = None): super().__init__(message) self.amount = amount self.transaction_id = transaction_id
class InsufficientFundsError(PaymentError): """Raised when account has insufficient funds""" pass
class PaymentProcessingError(PaymentError): """Raised when payment processing fails""" pass
def process_payment(amount: float, balance: float): """Process a payment""" if amount > balance: raise InsufficientFundsError( f"Insufficient funds. Required: {amount}, Available: {balance}", amount=amount, transaction_id="TXN-123" )
if amount < 0: raise PaymentProcessingError( "Invalid payment amount", amount=amount )
return {"status": "success", "amount": amount}
# Usagetry: process_payment(1000.0, 500.0)except InsufficientFundsError as e: print(f"Error: {e}") print(f"Amount: {e.amount}") print(f"Transaction ID: {e.transaction_id}")except PaymentProcessingError as e: print(f"Processing error: {e}")Exception Chaining
Section titled “Exception Chaining”Exception chaining preserves the original exception context:
Implicit Exception Chaining
Section titled “Implicit Exception Chaining”When an exception occurs while handling another exception:
def process_data(filename: str): """Process data from file""" try: with open(filename, 'r') as f: data = f.read() # Simulate processing error result = int(data) / 0 # This will raise ZeroDivisionError except FileNotFoundError: # This exception occurs while handling FileNotFoundError # Python automatically chains them raise ValueError("Could not process file") # Implicitly chains
# Usagetry: process_data("nonexistent.txt")except ValueError as e: print(f"ValueError: {e}") print(f"Caused by: {e.__cause__}") # The original FileNotFoundErrorExplicit Exception Chaining with raise from
Section titled “Explicit Exception Chaining with raise from”Use raise from to explicitly chain exceptions:
class DatabaseError(Exception): """Database operation error""" pass
class ConnectionError(DatabaseError): """Database connection error""" pass
def connect_to_database(): """Connect to database""" try: # Simulate connection error raise ConnectionError("Could not connect to database") except ConnectionError as e: # Chain with more context raise DatabaseError("Database operation failed") from e
# Usagetry: connect_to_database()except DatabaseError as e: print(f"Database error: {e}") print(f"Caused by: {e.__cause__}") # The ConnectionError print(f"Original traceback preserved!")Suppressing Exception Context
Section titled “Suppressing Exception Context”Use raise from None to suppress the original exception:
def process_user_input(value: str): """Process user input""" try: return int(value) except ValueError: # Suppress the original ValueError, raise new one raise TypeError(f"Expected integer, got {type(value).__name__}") from None
# Usagetry: process_user_input("abc")except TypeError as e: print(f"Type error: {e}") # Original ValueError is suppressedException Groups (Python 3.11+)
Section titled “Exception Groups (Python 3.11+)”Exception groups allow handling multiple exceptions simultaneously:
# Python 3.11+ featuredef validate_user_data(email: str, age: int, username: str): """Validate user data - may raise multiple errors""" errors = []
if "@" not in email: errors.append(ValueError(f"Invalid email: {email}"))
if age < 0: errors.append(ValueError(f"Invalid age: {age}"))
if len(username) < 3: errors.append(ValueError(f"Username too short: {username}"))
if errors: raise ExceptionGroup("Validation errors", errors)
# Usagetry: validate_user_data("invalid", -5, "ab")except* ValueError as eg: # Catches all ValueError exceptions in group for error in eg.exceptions: print(f"Error: {error}")Real-World Examples
Section titled “Real-World Examples”1. API Error Handling
Section titled “1. API Error Handling”class APIError(Exception): """Base exception for API errors""" def __init__(self, message: str, status_code: int, response_data: dict = None): super().__init__(message) self.status_code = status_code self.response_data = response_data or {}
class AuthenticationError(APIError): """Raised when authentication fails""" def __init__(self, message: str = "Authentication failed", response_data: dict = None): super().__init__(message, status_code=401, response_data=response_data)
class NotFoundError(APIError): """Raised when resource not found""" def __init__(self, resource: str, response_data: dict = None): message = f"Resource not found: {resource}" super().__init__(message, status_code=404, response_data=response_data)
class RateLimitError(APIError): """Raised when rate limit exceeded""" def __init__(self, retry_after: int, response_data: dict = None): message = f"Rate limit exceeded. Retry after {retry_after} seconds" super().__init__(message, status_code=429, response_data=response_data) self.retry_after = retry_after
def make_api_request(url: str): """Make API request""" import random
# Simulate different error scenarios error_type = random.choice(["auth", "not_found", "rate_limit", "success"])
if error_type == "auth": raise AuthenticationError(response_data={"error": "Invalid token"}) elif error_type == "not_found": raise NotFoundError("user/123", response_data={"error": "User not found"}) elif error_type == "rate_limit": raise RateLimitError(retry_after=60, response_data={"error": "Too many requests"}) else: return {"status": "success", "data": {"id": 123}}
# Usagetry: response = make_api_request("https://api.example.com/users") print(f"Success: {response}")except AuthenticationError as e: print(f"Auth error ({e.status_code}): {e}") # Retry with new tokenexcept NotFoundError as e: print(f"Not found ({e.status_code}): {e}") # Handle missing resourceexcept RateLimitError as e: print(f"Rate limit ({e.status_code}): Retry after {e.retry_after}s") # Wait and retryexcept APIError as e: print(f"API error ({e.status_code}): {e}") # Handle other API errors2. Database Error Handling
Section titled “2. Database Error Handling”class DatabaseError(Exception): """Base exception for database errors""" pass
class ConnectionError(DatabaseError): """Database connection error""" def __init__(self, message: str, host: str, port: int): super().__init__(message) self.host = host self.port = port
class QueryError(DatabaseError): """Database query error""" def __init__(self, message: str, query: str): super().__init__(message) self.query = query
class TransactionError(DatabaseError): """Database transaction error""" def __init__(self, message: str, transaction_id: str): super().__init__(message) self.transaction_id = transaction_id
def execute_query(query: str): """Execute database query""" try: # Simulate connection error if "SELECT" not in query.upper(): raise ConnectionError("Connection lost", host="localhost", port=5432)
# Simulate query error if "DROP" in query.upper(): raise QueryError("Invalid query", query=query)
return {"rows": [{"id": 1, "name": "Alice"}]}
except ConnectionError as e: # Chain with more context raise DatabaseError(f"Database operation failed: {query}") from e except QueryError as e: raise DatabaseError(f"Query execution failed") from e
# Usagetry: result = execute_query("DROP TABLE users")except DatabaseError as e: print(f"Database error: {e}") if e.__cause__: print(f"Caused by: {e.__cause__}") if isinstance(e.__cause__, QueryError): print(f"Problematic query: {e.__cause__.query}")3. Validation with Multiple Errors
Section titled “3. Validation with Multiple Errors”class ValidationError(Exception): """Base validation error""" def __init__(self, message: str, field: str): super().__init__(message) self.field = field
class RequiredFieldError(ValidationError): """Raised when required field is missing""" pass
class InvalidFormatError(ValidationError): """Raised when field format is invalid""" pass
class OutOfRangeError(ValidationError): """Raised when field value is out of range""" pass
def validate_user(data: dict): """Validate user data - collect all errors""" errors = []
# Validate email if "email" not in data: errors.append(RequiredFieldError("Email is required", field="email")) elif "@" not in data["email"]: errors.append(InvalidFormatError("Invalid email format", field="email"))
# Validate age if "age" not in data: errors.append(RequiredFieldError("Age is required", field="age")) elif not isinstance(data["age"], int): errors.append(InvalidFormatError("Age must be an integer", field="age")) elif data["age"] < 0 or data["age"] > 150: errors.append(OutOfRangeError("Age must be between 0 and 150", field="age"))
# Validate username if "username" not in data: errors.append(RequiredFieldError("Username is required", field="username")) elif len(data.get("username", "")) < 3: errors.append(InvalidFormatError("Username must be at least 3 characters", field="username"))
if errors: # Python 3.11+ exception groups try: raise ExceptionGroup("Validation failed", errors) except NameError: # Fallback for older Python versions error_messages = [f"{e.field}: {e}" for e in errors] raise ValidationError(f"Multiple validation errors: {', '.join(error_messages)}", field="multiple")
# Usagetry: validate_user({"email": "invalid", "age": -5})except ExceptionGroup as eg: for error in eg.exceptions: print(f"{error.field}: {error}")except ValidationError as e: print(f"Validation error: {e}")Exception Handling Best Practices
Section titled “Exception Handling Best Practices”1. Be Specific with Exception Types
Section titled “1. Be Specific with Exception Types”# ❌ Bad - too broadtry: process_data()except Exception as e: # Catches everything! print("Error occurred")
# ✅ Good - specific exceptionstry: process_data()except ValueError as e: print(f"Invalid value: {e}")except FileNotFoundError as e: print(f"File not found: {e}")except Exception as e: print(f"Unexpected error: {e}") # Only for truly unexpected errors2. Don’t Suppress Exceptions Silently
Section titled “2. Don’t Suppress Exceptions Silently”# ❌ Bad - silently suppresses errorstry: process_data()except Exception: pass # Error is lost!
# ✅ Good - log or handle appropriatelyimport logginglogger = logging.getLogger(__name__)
try: process_data()except ValueError as e: logger.error(f"Invalid data: {e}") raise # Re-raise if neededexcept Exception as e: logger.exception("Unexpected error occurred") raise # Re-raise unexpected errors3. Use Finally for Cleanup
Section titled “3. Use Finally for Cleanup”def process_file(filename: str): """Process file with proper cleanup""" file = None try: file = open(filename, 'r') data = file.read() process(data) except FileNotFoundError: print(f"File not found: {filename}") raise except Exception as e: print(f"Error processing file: {e}") raise finally: if file: file.close() # Always close, even if error occurs print("Cleanup complete")4. Provide Context with Exception Chaining
Section titled “4. Provide Context with Exception Chaining”# ❌ Bad - loses original error contextdef process_data(): try: result = complex_operation() except ValueError: raise RuntimeError("Processing failed") # Original error lost
# ✅ Good - preserves contextdef process_data(): try: result = complex_operation() except ValueError as e: raise RuntimeError("Processing failed") from e # Preserves context5. Create Meaningful Error Messages
Section titled “5. Create Meaningful Error Messages”# ❌ Bad - vague error messageclass BadError(Exception): pass
raise BadError("Error") # What error?
# ✅ Good - descriptive error messageclass GoodError(Exception): def __init__(self, field: str, value: any, reason: str): message = f"Invalid {field}: {value}. Reason: {reason}" super().__init__(message) self.field = field self.value = value self.reason = reason
raise GoodError("email", "invalid@", "Missing domain")Common Patterns
Section titled “Common Patterns”1. Retry with Exponential Backoff
Section titled “1. Retry with Exponential Backoff”import timeimport random
class RetryableError(Exception): """Error that can be retried""" pass
def retry_with_backoff(func, max_retries: int = 3, base_delay: float = 1.0): """Retry function with exponential backoff""" for attempt in range(max_retries): try: return func() except RetryableError as e: if attempt == max_retries - 1: raise # Last attempt, raise the error
delay = base_delay * (2 ** attempt) + random.uniform(0, 1) print(f"Attempt {attempt + 1} failed. Retrying in {delay:.2f}s...") time.sleep(delay)
raise Exception("Max retries exceeded")
def unreliable_operation(): """Operation that might fail""" if random.random() < 0.7: raise RetryableError("Temporary failure") return "Success"
# Usageresult = retry_with_backoff(unreliable_operation)print(result)2. Error Context Manager
Section titled “2. Error Context Manager”from contextlib import contextmanager
@contextmanagerdef error_context(operation: str): """Context manager that adds error context""" try: yield except Exception as e: raise RuntimeError(f"Error during {operation}") from e
# Usagewith error_context("data processing"): process_data() # Any error will include "data processing" context3. Exception Logging Decorator
Section titled “3. Exception Logging Decorator”import loggingfrom functools import wraps
logger = logging.getLogger(__name__)
def log_exceptions(func): """Decorator that logs exceptions""" @wraps(func) def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: logger.exception(f"Exception in {func.__name__}: {e}") raise return wrapper
@log_exceptionsdef risky_operation(): """Operation that might raise exceptions""" raise ValueError("Something went wrong")
# Usagetry: risky_operation()except ValueError: pass # Exception was loggedCommon Mistakes to Avoid
Section titled “Common Mistakes to Avoid”Mistake 1: Catching Too Broad
Section titled “Mistake 1: Catching Too Broad”# ❌ Bad - catches everythingtry: process_data()except Exception: # Too broad! pass
# ✅ Good - catch specific exceptionstry: process_data()except ValueError: handle_value_error()except FileNotFoundError: handle_file_error()Mistake 2: Ignoring Exceptions
Section titled “Mistake 2: Ignoring Exceptions”# ❌ Bad - ignores errorstry: critical_operation()except Exception: pass # Error is ignored!
# ✅ Good - handle or logtry: critical_operation()except Exception as e: logger.error(f"Critical operation failed: {e}") raise # Re-raise if criticalMistake 3: Losing Exception Context
Section titled “Mistake 3: Losing Exception Context”# ❌ Bad - loses original exceptiontry: operation()except ValueError: raise RuntimeError("Failed") # Original ValueError lost
# ✅ Good - preserve contexttry: operation()except ValueError as e: raise RuntimeError("Failed") from e # Preserves ValueErrorBest Practices
Section titled “Best Practices”- Use specific exceptions - Catch what you expect, handle specifically
- Preserve context - Use
raise fromto chain exceptions - Log exceptions - Always log errors for debugging
- Clean up resources - Use
finallyfor cleanup - Create custom exceptions - For domain-specific errors
- Provide context - Include relevant data in exceptions
- Don’t suppress silently - Always handle or re-raise
- Document exceptions - Document what exceptions functions can raise
Key Takeaways
Section titled “Key Takeaways”- Custom exceptions - Create domain-specific error types
- Exception chaining - Use
raise fromto preserve context - Exception groups - Handle multiple exceptions (Python 3.11+)
- Be specific - Catch specific exception types
- Preserve context - Don’t lose original exception information
- Log errors - Always log exceptions for debugging
- Clean up - Use
finallyfor resource cleanup - Meaningful messages - Provide descriptive error messages
Remember: Handle errors gracefully - use custom exceptions, preserve context, and always clean up resources! 🛡️