Best Practices for Error Handling in Python

Week 3: Python Fundamentals - Building Robust Applications

Introduction to Error Handling Best Practices

Welcome to our in-depth exploration of error handling best practices in Python! Well-designed error handling is what separates production-quality code from fragile prototypes. As the saying goes, "It's not just about whether your code works—it's about how gracefully it fails."

Error handling isn't just about preventing crashes. It's about creating a better experience for users, developers, and maintainers of your code. It's about making your software robust in the face of unexpected situations, and making it easier to diagnose and fix problems when they occur.

Folder Structure for Today's Examples

error_handling_best_practices/
├── principles/
│   ├── specific_exceptions.py
│   ├── fail_fast.py
│   ├── clean_up_resources.py
│   └── error_propagation.py
├── patterns/
│   ├── custom_exceptions.py
│   ├── exception_hierarchies.py
│   ├── error_translation.py
│   └── context_managers.py
├── practical/
│   ├── file_operations.py
│   ├── network_requests.py
│   ├── database_access.py
│   └── external_apis.py
└── exercises/
    ├── refactoring_exercise.py
    ├── application_exercise.py
    └── solutions/
        ├── refactoring_solution.py
        └── application_solution.py
                

Fundamental Principles of Effective Error Handling

1. Be Specific About Which Exceptions You Catch

One of the most important principles in Python error handling is to catch only the specific exceptions you expect and can handle properly. Avoid the temptation to use a bare except: clause, which catches all exceptions, including those you might not be prepared to handle.

❌ Avoid This

# File: principles/specific_exceptions_bad.py

def process_file(filename):
    try:
        with open(filename, 'r') as file:
            return file.read()
    except:  # Bare except clause - catches ALL exceptions
        return "An error occurred"
                        

This code will catch everything—including KeyboardInterrupt, SystemExit, MemoryError, and other exceptions that should generally propagate up. It also provides no information about what went wrong.

✅ Do This Instead

# File: principles/specific_exceptions_good.py

def process_file(filename):
    try:
        with open(filename, 'r') as file:
            return file.read()
    except FileNotFoundError:
        return f"The file '{filename}' was not found"
    except PermissionError:
        return f"You don't have permission to read '{filename}'"
    except Exception as e:
        # Only as a last resort, catch Exception but with specific handling
        error_message = f"An unexpected error occurred: {e}"
        # In a real application, you might log this error
        print(error_message)
        return error_message
                        

This improved version catches specific exceptions with tailored responses for each. Even when it catches Exception as a fallback, it provides meaningful information about what happened.

Why This Matters: Being specific about which exceptions you catch makes your code more robust and predictable. It allows you to handle expected error conditions appropriately while letting unexpected errors propagate up the call stack where they might be better handled.

2. Provide Meaningful Error Messages

Error messages should be informative and helpful for both users and developers. They should explain what went wrong and, when appropriate, suggest how to fix it.

❌ Avoid This

# File: principles/error_messages_bad.py

def validate_age(age):
    try:
        age = int(age)
        if age < 0 or age > 120:
            raise ValueError()
        return age
    except ValueError:
        raise ValueError("Invalid age")
                        

This error message is vague and doesn't explain why the age is invalid or what would constitute a valid value.

✅ Do This Instead

# File: principles/error_messages_good.py

def validate_age(age):
    try:
        age = int(age)
    except ValueError:
        raise ValueError(f"Age must be a number, got '{age}'")
        
    if age < 0:
        raise ValueError("Age cannot be negative")
    elif age > 120:
        raise ValueError("Age must be less than or equal to 120")
        
    return age
                        

This version provides clear, specific error messages that explain exactly what went wrong and implicitly suggest how to fix it.

Why This Matters: Meaningful error messages significantly improve the debugging experience and can help guide users when something goes wrong. They reduce the time spent diagnosing issues and make your code more maintainable.

3. Fail Fast and Validate Inputs

Detect and report errors as early as possible, rather than letting invalid values propagate through your code. This principle, known as "fail fast," helps isolate problems and makes debugging easier.

❌ Avoid This

# File: principles/fail_fast_bad.py

def calculate_discount(price, discount_percentage):
    # No validation, errors might occur later
    discount = price * (discount_percentage / 100)
    final_price = price - discount
    return final_price
                        

This function doesn't validate its inputs. If price is negative or discount_percentage is 200%, it will return a surprising or nonsensical result.

✅ Do This Instead

# File: principles/fail_fast_good.py

def calculate_discount(price, discount_percentage):
    # Validate inputs immediately
    if not isinstance(price, (int, float)) or not isinstance(discount_percentage, (int, float)):
        raise TypeError("Price and discount percentage must be numbers")
        
    if price < 0:
        raise ValueError("Price cannot be negative")
        
    if discount_percentage < 0 or discount_percentage > 100:
        raise ValueError("Discount percentage must be between 0 and 100")
    
    # Now we can safely proceed with calculation
    discount = price * (discount_percentage / 100)
    final_price = price - discount
    return final_price
                        

This version validates inputs immediately and raises appropriate exceptions with clear messages if the inputs are invalid.

Why This Matters: Failing fast helps catch errors early, making them easier to diagnose and fix. It also prevents cascading failures where invalid values cause obscure errors deep in your codebase.

4. Clean Up Resources Properly

Always ensure that resources like files, network connections, and database connections are properly closed, even if exceptions occur. Python's with statement (context managers) is designed for exactly this purpose.

❌ Avoid This

# File: principles/clean_up_resources_bad.py

def save_data(data, filename):
    file = open(filename, 'w')
    file.write(data)
    file.close()  # This only happens if no exceptions occur
                        

If an exception occurs during file.write(), the file will never be closed, potentially leading to resource leaks.

✅ Do This Instead

# File: principles/clean_up_resources_good.py

def save_data(data, filename):
    with open(filename, 'w') as file:
        file.write(data)
    # File is automatically closed, even if an exception occurs
                        

Using a context manager with the with statement ensures the file is always closed, regardless of whether an exception occurs.

Why This Matters: Proper resource cleanup prevents resource leaks and other problems that can degrade application performance or cause it to fail over time. Context managers make this clean and straightforward in Python.

5. Don't Suppress Exceptions Without Good Reason

Catch exceptions only when you have a specific recovery action to take. If you can't handle an exception appropriately, it's often better to let it propagate up the call stack.

❌ Avoid This

# File: principles/suppress_exceptions_bad.py

def get_config_value(key):
    try:
        with open('config.ini', 'r') as file:
            for line in file:
                if line.startswith(f"{key}="):
                    return line.split('=')[1].strip()
    except Exception:
        # Silently ignoring all errors
        pass
    return None  # Default value if anything goes wrong
                        

This code catches and silently ignores all exceptions, making it very difficult to diagnose problems when they occur.

✅ Do This Instead

# File: principles/suppress_exceptions_good.py

def get_config_value(key, default=None):
    try:
        with open('config.ini', 'r') as file:
            for line in file:
                if line.startswith(f"{key}="):
                    return line.split('=')[1].strip()
    except FileNotFoundError:
        # Only suppress specific exceptions with a good reason
        print("Warning: Config file not found, using default values")
        return default
    except Exception as e:
        # Log unexpected errors before re-raising or handling them
        print(f"Error reading config: {e}")
        raise  # Re-raise the exception
    
    # If key wasn't found but file was read successfully
    return default
                        

This version only suppresses a specific exception with a valid reason and logs it. Other exceptions are logged and re-raised.

Why This Matters: Silently suppressing exceptions can mask serious problems and make debugging nearly impossible. Be deliberate about which exceptions you catch and how you handle them.

Advanced Error Handling Patterns

Custom Exception Classes

Creating custom exception classes tailored to your application's needs can make your error handling more expressive and easier to use.

Creating and Using Custom Exceptions

# File: patterns/custom_exceptions.py

class ConfigError(Exception):
    """Base exception for configuration-related errors."""
    pass

class ConfigFileNotFoundError(ConfigError):
    """Raised when the configuration file is not found."""
    def __init__(self, filename):
        self.filename = filename
        super().__init__(f"Configuration file not found: {filename}")

class ConfigParseError(ConfigError):
    """Raised when there's an error parsing the configuration."""
    def __init__(self, filename, line_number, message):
        self.filename = filename
        self.line_number = line_number
        super().__init__(f"Error parsing {filename} at line {line_number}: {message}")

def load_config(filename):
    try:
        with open(filename, 'r') as file:
            config = {}
            for i, line in enumerate(file, 1):
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                    
                if '=' not in line:
                    raise ConfigParseError(filename, i, "Missing '=' character")
                    
                key, value = line.split('=', 1)
                config[key.strip()] = value.strip()
            
            return config
    except FileNotFoundError:
        raise ConfigFileNotFoundError(filename)

# Using the custom exceptions
try:
    config = load_config('app_settings.ini')
    print("Configuration loaded successfully")
except ConfigFileNotFoundError as e:
    print(f"Configuration error: {e}")
    print("Using default configuration...")
    config = {'debug': 'False', 'log_level': 'INFO'}
except ConfigParseError as e:
    print(f"Configuration error: {e}")
    print(f"Please fix line {e.line_number} in {e.filename}")
    raise  # Re-raise after logging, as this is a critical error
except ConfigError as e:
    # Catch any other config errors
    print(f"Configuration error: {e}")
    raise  # Re-raise after logging
                    

This example creates a hierarchy of custom exceptions specific to configuration handling. Custom exceptions can include additional attributes (like filename and line_number) that provide context for the error.

Key Benefits:

  • Provides more context-specific information for error diagnosis
  • Allows for more granular exception handling
  • Makes code more self-documenting by explicitly naming the types of errors that can occur
  • Enables creation of exception hierarchies that match your application's domain model

Exception Translation

Sometimes it's useful to catch low-level exceptions and "translate" them into higher-level exceptions that are more meaningful in your application's context.

Translating Low-Level Exceptions

# File: patterns/error_translation.py

class DatabaseError(Exception):
    """Base exception for database-related errors."""
    pass

class ConnectionError(DatabaseError):
    """Raised when a database connection fails."""
    pass

class QueryError(DatabaseError):
    """Raised when a database query fails."""
    pass

def execute_query(query, parameters=None):
    """
    Execute a database query with proper error handling.
    
    For demonstration, we'll simulate database operations.
    In real code, you'd use a database driver like sqlite3, psycopg2, etc.
    """
    try:
        # Simulate database connection and query execution
        if "SELECT" not in query.upper():
            raise ValueError("Only SELECT queries are supported in this example")
            
        # Simulate a connection error
        if "nonexistent_table" in query:
            raise FileNotFoundError("Table not found")
            
        # Simulate other database errors
        if "invalid_column" in query:
            raise KeyError("Column not found")
        
        # Simulate successful query execution
        print(f"Executing query: {query}")
        if parameters:
            print(f"With parameters: {parameters}")
        
        # Return simulated results
        return [{"id": 1, "name": "Result 1"}, {"id": 2, "name": "Result 2"}]
        
    except FileNotFoundError as e:
        # Translate to our custom exception
        raise QueryError(f"Table not found: {e}") from e
        
    except KeyError as e:
        # Translate to our custom exception
        raise QueryError(f"Invalid column: {e}") from e
        
    except ValueError as e:
        # Translate to our custom exception
        raise QueryError(f"Invalid query: {e}") from e
        
    except Exception as e:
        # Catch-all for unexpected errors
        raise DatabaseError(f"Unexpected database error: {e}") from e

# Using the exception translation
try:
    results = execute_query("SELECT * FROM users WHERE status = ?", ["active"])
    print(f"Found {len(results)} results")
except QueryError as e:
    print(f"Query error: {e}")
    # We can also access the original exception
    if e.__cause__:
        print(f"Original error: {e.__cause__}")
except DatabaseError as e:
    print(f"Database error: {e}")
                    

This example demonstrates how to catch low-level exceptions (like FileNotFoundError, KeyError, etc.) and translate them into application-specific exceptions that are more meaningful to users of your code.

Key Benefits:

  • Provides a more consistent error handling interface by normalizing different underlying errors
  • Adds domain-specific context to technical errors
  • Abstracts away the details of underlying libraries or systems
  • Preserves the original exception through the from clause, maintaining the full error context

Context Managers for Resource Management

The with statement and context managers provide a clean, pythonic way to ensure proper resource acquisition and release, even when exceptions occur.

Creating Custom Context Managers

# File: patterns/context_managers.py

class DatabaseConnection:
    """A context manager for handling database connections."""
    
    def __init__(self, connection_string):
        self.connection_string = connection_string
        self.connection = None
    
    def __enter__(self):
        """Set up the database connection."""
        print(f"Connecting to database: {self.connection_string}")
        # In a real application, this would use a database driver
        # self.connection = db.connect(self.connection_string)
        self.connection = {"connected": True}  # Simulated connection
        return self.connection
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        """Clean up the database connection."""
        print("Closing database connection")
        if self.connection:
            # In a real application, you would call connection.close()
            self.connection = None

# Using a custom context manager
def fetch_user_data(user_id):
    with DatabaseConnection("postgresql://localhost/users") as conn:
        # In a real application, you would execute a query here
        print(f"Fetching data for user {user_id}")
        
        # Simulate fetching data
        if user_id < 0:
            raise ValueError("User ID cannot be negative")
            
        # Return simulated results
        return {"id": user_id, "name": f"User {user_id}"}

# Using contextlib.contextmanager for simpler context managers
from contextlib import contextmanager

@contextmanager
def transaction(connection):
    """A context manager for database transactions."""
    try:
        print("Beginning transaction")
        # In a real application: connection.begin()
        yield  # This is where the code in the with block executes
        print("Committing transaction")
        # In a real application: connection.commit()
    except Exception as e:
        print(f"Rolling back transaction: {e}")
        # In a real application: connection.rollback()
        raise  # Re-raise the exception

# Using the transaction context manager
def update_user(user_id, data):
    with DatabaseConnection("postgresql://localhost/users") as conn:
        with transaction(conn):
            print(f"Updating user {user_id} with {data}")
            
            # Simulate an error during update
            if 'error' in data:
                raise ValueError("Invalid data for update")
            
            print("User updated successfully")

# Testing our context managers
try:
    result = fetch_user_data(42)
    print(f"User data: {result}")
except Exception as e:
    print(f"Error fetching user data: {e}")

try:
    update_user(42, {"name": "New Name"})
except Exception as e:
    print(f"Error updating user: {e}")

try:
    update_user(42, {"name": "Error Test", "error": True})
except Exception as e:
    print(f"Error updating user: {e}")
                    

This example shows two ways to create context managers: a class-based approach implementing __enter__ and __exit__ methods, and a function-based approach using the @contextmanager decorator from the contextlib module.

Key Benefits:

  • Ensures resources are properly cleaned up, even if exceptions occur
  • Simplifies code by eliminating explicit try/finally blocks
  • Makes resource lifecycle management more declarative and less error-prone
  • Can be nested to manage multiple resources with proper cleanup ordering

Error Handling Middleware

For larger applications, especially web applications, it's often useful to implement an error handling middleware layer that provides consistent error handling across the entire application.

Implementing Error Handling Middleware

# File: patterns/error_middleware.py

def execute_with_error_handling(func, *args, **kwargs):
    """
    Execute a function with standardized error handling.
    
    This is a simple example of an error handling middleware pattern
    that could be used to wrap API endpoints or other function calls.
    """
    try:
        return {
            "status": "success",
            "data": func(*args, **kwargs)
        }
    except ValueError as e:
        return {
            "status": "error",
            "error_type": "validation_error",
            "message": str(e)
        }
    except KeyError as e:
        return {
            "status": "error",
            "error_type": "not_found",
            "message": f"Resource not found: {e}"
        }
    except Exception as e:
        # Log unexpected errors
        print(f"Unexpected error in {func.__name__}: {e}")
        return {
            "status": "error",
            "error_type": "internal_error",
            "message": "An internal error occurred"
        }

# Example functions to use with the middleware
def get_user(user_id):
    users = {1: "Alice", 2: "Bob"}
    if not isinstance(user_id, int):
        raise ValueError("User ID must be an integer")
    
    if user_id not in users:
        raise KeyError(user_id)
        
    return {"id": user_id, "name": users[user_id]}

def update_user(user_id, data):
    if not isinstance(user_id, int):
        raise ValueError("User ID must be an integer")
        
    if not data:
        raise ValueError("Data cannot be empty")
        
    # Simulate a successful update
    return {"id": user_id, "updated": True}

# Test the middleware
print("Getting user 1:")
result = execute_with_error_handling(get_user, 1)
print(result)

print("\nGetting user 3 (doesn't exist):")
result = execute_with_error_handling(get_user, 3)
print(result)

print("\nGetting user with invalid ID:")
result = execute_with_error_handling(get_user, "not-an-id")
print(result)

print("\nUpdating user with valid data:")
result = execute_with_error_handling(update_user, 1, {"name": "Alice Smith"})
print(result)

print("\nUpdating user with invalid data:")
result = execute_with_error_handling(update_user, 1, None)
print(result)
                    

This example demonstrates a simple error handling middleware function that standardizes error responses across different function calls. In a real web application, this pattern would be implemented at the framework level (e.g., using middleware in Flask or Django).

Key Benefits:

  • Provides consistent error handling and reporting across the application
  • Centralizes error handling logic, reducing duplication
  • Makes it easier to implement global error policies like logging or monitoring
  • Separates error handling concerns from business logic

Practical Examples for Common Scenarios

File Operations

File operations are prone to various errors, from missing files to permission issues. Here's how to handle them robustly:

Robust File Operations

# File: practical/file_operations.py

import os
import json
from pathlib import Path

def read_json_file(filepath):
    """
    Read and parse a JSON file with robust error handling.
    
    Args:
        filepath: Path to the JSON file
        
    Returns:
        The parsed JSON data
        
    Raises:
        FileNotFoundError: If the file doesn't exist
        PermissionError: If the file can't be read due to permissions
        json.JSONDecodeError: If the file contains invalid JSON
        ValueError: If the file has an invalid extension
    """
    # Convert to Path object for consistent path handling
    path = Path(filepath)
    
    # Validate file extension
    if path.suffix.lower() != '.json':
        raise ValueError(f"Expected a JSON file, got: {path.suffix}")
    
    # Try to read and parse the file
    try:
        with open(path, 'r', encoding='utf-8') as file:
            return json.load(file)
    except FileNotFoundError:
        # Re-raise with more context
        raise FileNotFoundError(f"JSON file not found: {path}")
    except PermissionError:
        raise PermissionError(f"Permission denied when reading: {path}")
    except json.JSONDecodeError as e:
        # Add file context to the error
        raise json.JSONDecodeError(
            f"{e.msg} in file {path}", e.doc, e.pos
        ) from e

def write_json_file(data, filepath, indent=2):
    """
    Write data to a JSON file with robust error handling.
    
    Args:
        data: The data to write (must be JSON-serializable)
        filepath: Path to write the JSON file
        indent: Number of spaces for indentation (default: 2)
        
    Returns:
        The absolute path to the created file
        
    Raises:
        PermissionError: If the file can't be written due to permissions
        TypeError: If the data is not JSON-serializable
        ValueError: If the file has an invalid extension
    """
    # Convert to Path object
    path = Path(filepath)
    
    # Validate file extension
    if path.suffix.lower() != '.json':
        raise ValueError(f"Expected a JSON file, got: {path.suffix}")
    
    # Create directory if it doesn't exist
    os.makedirs(path.parent, exist_ok=True)
    
    # Try to write the file
    try:
        with open(path, 'w', encoding='utf-8') as file:
            json.dump(data, file, indent=indent)
        return str(path.absolute())
    except PermissionError:
        raise PermissionError(f"Permission denied when writing to: {path}")
    except TypeError as e:
        raise TypeError(f"Data is not JSON-serializable: {e}")

# Example usage
def demonstrate_file_operations():
    # Test reading a valid JSON file
    try:
        data = read_json_file('config.json')
        print(f"Successfully read JSON data: {data}")
    except FileNotFoundError as e:
        print(f"File error: {e}")
    except json.JSONDecodeError as e:
        print(f"JSON parsing error: {e}")
    except Exception as e:
        print(f"Unexpected error: {e}")
    
    # Test writing a JSON file
    try:
        data = {
            "name": "Example Config",
            "version": 1.0,
            "settings": {
                "debug": True,
                "log_level": "INFO"
            }
        }
        path = write_json_file(data, 'output/new_config.json')
        print(f"Successfully wrote JSON data to: {path}")
    except PermissionError as e:
        print(f"Permission error: {e}")
    except TypeError as e:
        print(f"Data error: {e}")
    except Exception as e:
        print(f"Unexpected error: {e}")

# Run the demonstration
# demonstrate_file_operations()
                    

This example demonstrates robust error handling for JSON file operations, including handling specific file-related exceptions, adding context to errors, and ensuring proper cleanup with context managers.

Network Requests

Network operations are inherently prone to errors like timeouts, connection failures, and unexpected responses. Here's how to handle them gracefully:

Robust Network Operations

# File: practical/network_requests.py

import time
import json
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from urllib.parse import urlencode

class APIError(Exception):
    """Base exception for API-related errors."""
    pass

class APIConnectionError(APIError):
    """Raised when a connection to the API fails."""
    pass

class APITimeoutError(APIError):
    """Raised when an API request times out."""
    pass

class APIResponseError(APIError):
    """Raised when the API returns an error response."""
    def __init__(self, status_code, message):
        self.status_code = status_code
        super().__init__(f"API returned error {status_code}: {message}")

def api_request(url, method="GET", data=None, headers=None, timeout=10, retries=3, retry_delay=1):
    """
    Make an API request with robust error handling and retry logic.
    
    Args:
        url: The URL to request
        method: HTTP method (default: GET)
        data: Request body data (for POST/PUT)
        headers: Request headers
        timeout: Request timeout in seconds
        retries: Number of times to retry on failure
        retry_delay: Delay between retries in seconds
        
    Returns:
        The parsed JSON response
        
    Raises:
        APIConnectionError: If the connection fails
        APITimeoutError: If the request times out
        APIResponseError: If the API returns an error status
        APIError: For other API-related errors
    """
    # Prepare the request
    request_headers = headers or {}
    request_headers.setdefault('Content-Type', 'application/json')
    
    # Encode data if provided
    request_data = None
    if data:
        if method == "GET":
            # For GET, add data as query parameters
            query_string = urlencode(data)
            url = f"{url}?{query_string}" if '?' not in url else f"{url}&{query_string}"
        else:
            # For other methods, encode as JSON
            request_data = json.dumps(data).encode('utf-8')
    
    # Create the request
    request = Request(
        url=url,
        data=request_data,
        headers=request_headers,
        method=method
    )
    
    # Retry loop
    for attempt in range(retries):
        try:
            start_time = time.time()
            
            # Make the request
            with urlopen(request, timeout=timeout) as response:
                # Read and parse the response
                response_data = response.read().decode('utf-8')
                
                # Check if the response is valid JSON
                try:
                    result = json.loads(response_data)
                    return result
                except json.JSONDecodeError as e:
                    raise APIError(f"Invalid JSON response: {e}")
                
        except HTTPError as e:
            # Handle HTTP error responses
            try:
                # Try to parse the error response
                error_message = e.read().decode('utf-8')
                try:
                    error_json = json.loads(error_message)
                    error_message = error_json.get('message', error_message)
                except json.JSONDecodeError:
                    # Not JSON, use the raw message
                    pass
            except Exception:
                # Couldn't read the error response
                error_message = str(e)
            
            # Check if we should retry
            if e.code >= 500 and attempt < retries - 1:
                # Server error, might be transient
                print(f"API server error (attempt {attempt + 1}/{retries}): {e.code}")
                time.sleep(retry_delay)
                continue
                
            # Raise an appropriate error
            raise APIResponseError(e.code, error_message)
            
        except URLError as e:
            # Handle connection errors
            if "timeout" in str(e.reason).lower():
                if attempt < retries - 1:
                    # Timeout, retry
                    print(f"API request timed out (attempt {attempt + 1}/{retries})")
                    time.sleep(retry_delay)
                    continue
                else:
                    # Max retries reached
                    raise APITimeoutError(f"API request timed out after {retries} attempts")
            else:
                # Other connection error
                raise APIConnectionError(f"Connection error: {e.reason}")
                
        except Exception as e:
            # Handle other unexpected errors
            raise APIError(f"Unexpected error during API request: {e}")
            
        # If we get here, the request was successful
        break
    
    # This should never happen (we either return or raise above)
    raise APIError("Unexpected end of API request function")

# Example usage
def get_user_data(user_id):
    """
    Get user data from a hypothetical API.
    
    In a real application, you'd use a proper API client.
    For this example, we'll use a public JSON placeholder API.
    """
    try:
        response = api_request(
            url=f"https://jsonplaceholder.typicode.com/users/{user_id}",
            headers={"Accept": "application/json"},
            timeout=5
        )
        
        # Process the response
        return {
            "id": response.get("id"),
            "name": response.get("name"),
            "email": response.get("email")
        }
    except APIResponseError as e:
        if e.status_code == 404:
            # User not found
            print(f"User with ID {user_id} not found")
            return None
        # Re-raise other API errors
        raise
    except APIError as e:
        # Handle API errors
        print(f"API error: {e}")
        # Depending on the use case, you might want to re-raise or return None
        return None

# Run the demonstration
# try:
#     user = get_user_data(1)
#     if user:
#         print(f"User: {user}")
# except Exception as e:
#     print(f"Unexpected error: {e}")
                    

This example demonstrates robust error handling for network requests, including specific exception types for different network-related errors, retry logic for transient issues, and proper resource cleanup using context managers.

Putting It All Together: A Complete Example

Configuration Manager with Comprehensive Error Handling

This complete example integrates many of the principles and patterns we've discussed to create a robust configuration management system.

# File: practical/config_manager.py

import os
import json
import logging
from pathlib import Path
from typing import Any, Dict, Optional

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("ConfigManager")

# Custom exceptions
class ConfigError(Exception):
    """Base exception for configuration-related errors."""
    pass

class ConfigFileError(ConfigError):
    """Raised when there's an issue with the configuration file."""
    pass

class ConfigParseError(ConfigError):
    """Raised when parsing the configuration fails."""
    pass

class ConfigValidationError(ConfigError):
    """Raised when configuration validation fails."""
    pass

class ConfigManager:
    """A robust configuration manager with comprehensive error handling."""
    
    def __init__(self, app_name: str, config_dir: Optional[str] = None):
        """
        Initialize the configuration manager.
        
        Args:
            app_name: Name of the application (used for default directories)
            config_dir: Custom configuration directory (optional)
        """
        self.app_name = app_name
        
        # Set up configuration directory
        if config_dir:
            self.config_dir = Path(config_dir)
        else:
            # Use a default location based on platform
            if os.name == 'nt':  # Windows
                self.config_dir = Path(os.environ.get('APPDATA', '')) / app_name
            else:  # Unix/Linux/Mac
                self.config_dir = Path.home() / f".{app_name.lower()}"
        
        # Set up configuration file paths
        self.config_file = self.config_dir / "config.json"
        self.defaults_file = self.config_dir / "defaults.json"
        
        # Initialize configuration dictionary
        self.config = {}
        self.defaults = {}
        
        logger.info(f"Initialized ConfigManager for {app_name}")
        logger.info(f"Configuration directory: {self.config_dir}")
    
    def load(self, validate: bool = True) -> Dict[str, Any]:
        """
        Load configuration from files.
        
        Args:
            validate: Whether to validate the configuration
            
        Returns:
            The loaded configuration dictionary
            
        Raises:
            ConfigFileError: If there's an issue with the configuration files
            ConfigParseError: If parsing the configuration fails
            ConfigValidationError: If validation fails
        """
        logger.info("Loading configuration")
        
        # Load defaults
        try:
            self.defaults = self._load_config_file(self.defaults_file, required=False)
            logger.info(f"Loaded defaults: {len(self.defaults)} settings")
        except ConfigError as e:
            logger.warning(f"Could not load defaults: {e}")
            self.defaults = {}
        
        # Start with defaults
        self.config = self.defaults.copy() if self.defaults else {}
        
        # Load user configuration
        try:
            user_config = self._load_config_file(self.config_file, required=False)
            if user_config:
                logger.info(f"Loaded user configuration: {len(user_config)} settings")
                
                # Update defaults with user settings
                self.config.update(user_config)
        except ConfigError as e:
            logger.warning(f"Could not load user configuration: {e}")
        
        # Validate if requested
        if validate and self.config:
            try:
                self._validate_config(self.config)
                logger.info("Configuration validation passed")
            except ConfigValidationError as e:
                logger.error(f"Configuration validation failed: {e}")
                raise
        
        return self.config
    
    def save(self) -> None:
        """
        Save the current configuration to file.
        
        Raises:
            ConfigFileError: If there's an issue saving the configuration file
        """
        logger.info("Saving configuration")
        
        # Ensure the configuration directory exists
        try:
            os.makedirs(self.config_dir, exist_ok=True)
        except OSError as e:
            raise ConfigFileError(f"Could not create configuration directory: {e}")
        
        # Save the configuration
        try:
            with open(self.config_file, 'w', encoding='utf-8') as f:
                json.dump(self.config, f, indent=2)
            logger.info(f"Configuration saved to {self.config_file}")
        except (OSError, TypeError) as e:
            raise ConfigFileError(f"Error saving configuration: {e}")
    
    def get(self, key: str, default: Any = None) -> Any:
        """
        Get a configuration value.
        
        Args:
            key: Configuration key
            default: Default value if the key is not found
            
        Returns:
            The configuration value or default
        """
        return self.config.get(key, default)
    
    def set(self, key: str, value: Any) -> None:
        """
        Set a configuration value.
        
        Args:
            key: Configuration key
            value: Configuration value
        """
        self.config[key] = value
    
    def _load_config_file(self, filepath: Path, required: bool = True) -> Dict[str, Any]:
        """
        Load a configuration file.
        
        Args:
            filepath: Path to the configuration file
            required: Whether the file is required to exist
            
        Returns:
            The loaded configuration dictionary
            
        Raises:
            ConfigFileError: If there's an issue with the configuration file
            ConfigParseError: If parsing the configuration fails
        """
        # Check if the file exists
        if not filepath.exists():
            if required:
                raise ConfigFileError(f"Configuration file not found: {filepath}")
            else:
                logger.info(f"Configuration file not found (optional): {filepath}")
                return {}
        
        # Try to load the configuration
        try:
            with open(filepath, 'r', encoding='utf-8') as f:
                try:
                    return json.load(f)
                except json.JSONDecodeError as e:
                    raise ConfigParseError(f"Invalid JSON in {filepath}: {e}") from e
        except OSError as e:
            raise ConfigFileError(f"Error reading {filepath}: {e}")
    
    def _validate_config(self, config: Dict[str, Any]) -> None:
        """
        Validate configuration values.
        
        In a real application, this would check for required fields,
        type validation, range validation, etc.
        
        Args:
            config: Configuration dictionary to validate
            
        Raises:
            ConfigValidationError: If validation fails
        """
        # Example validation: check for required fields
        required_fields = ['log_level', 'debug']
        missing_fields = [field for field in required_fields if field not in config]
        
        if missing_fields:
            raise ConfigValidationError(
                f"Missing required configuration fields: {', '.join(missing_fields)}"
            )
        
        # Example validation: check field types
        if not isinstance(config.get('debug'), bool):
            raise ConfigValidationError(
                f"Field 'debug' must be a boolean, got: {type(config.get('debug')).__name__}"
            )
        
        # Example validation: check field values
        valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
        if config.get('log_level') not in valid_log_levels:
            raise ConfigValidationError(
                f"Invalid log_level: {config.get('log_level')}. "
                f"Must be one of: {', '.join(valid_log_levels)}"
            )

# Example usage
def demonstrate_config_manager():
    # Create a configuration manager
    config_manager = ConfigManager("MyApp")
    
    try:
        # Try to load the configuration
        config = config_manager.load(validate=False)
        print(f"Loaded configuration: {config}")
        
        # Set some values
        config_manager.set('debug', True)
        config_manager.set('log_level', 'INFO')
        config_manager.set('max_threads', 4)
        
        # Save the configuration
        config_manager.save()
        print("Configuration saved")
        
        # Load and validate
        try:
            config = config_manager.load(validate=True)
            print("Configuration validated successfully")
        except ConfigValidationError as e:
            print(f"Validation error: {e}")
    except ConfigError as e:
        print(f"Configuration error: {e}")
    except Exception as e:
        print(f"Unexpected error: {e}")

# Run the demonstration
# demonstrate_config_manager()
                    

This example demonstrates a robust configuration management system that incorporates many error handling best practices:

  • Custom exception hierarchy for different error types
  • Specific error handling for different failure scenarios
  • Proper cleanup with context managers
  • Detailed logging for operational visibility
  • Clear and specific error messages
  • Input validation for early error detection

Exercises to Reinforce Learning

Exercise 1: Refactoring for Better Error Handling

In this exercise, you'll refactor code with poor error handling to apply the best practices we've discussed.

Code to Refactor

# File: exercises/refactoring_exercise.py

def parse_data(filename):
    """Parse data from a file and return the sum of numeric values."""
    f = open(filename, 'r')
    lines = f.readlines()
    f.close()
    
    total = 0
    for line in lines:
        value = int(line.strip())
        total += value
    
    return total

# Your task: Refactor this function to include proper error handling
# - Use context managers for file handling
# - Add specific exception handling for different error scenarios
# - Provide meaningful error messages
# - Consider what should happen with invalid data lines
# - Document the function with clear error handling information
                    

Example Solution

# File: exercises/solutions/refactoring_solution.py

def parse_data(filename, skip_invalid=False):
    """
    Parse data from a file and return the sum of numeric values.
    
    Args:
        filename: Path to the data file
        skip_invalid: Whether to skip invalid lines (default: False)
        
    Returns:
        A dictionary containing the total sum and processing statistics
        
    Raises:
        FileNotFoundError: If the file doesn't exist
        PermissionError: If the file can't be read due to permissions
        ValueError: If a line contains non-numeric data and skip_invalid is False
    """
    try:
        with open(filename, 'r') as file:
            lines = file.readlines()
    except FileNotFoundError:
        raise FileNotFoundError(f"Data file not found: {filename}")
    except PermissionError:
        raise PermissionError(f"Permission denied when reading: {filename}")
    except Exception as e:
        raise IOError(f"Error reading file {filename}: {e}")
    
    total = 0
    processed_lines = 0
    skipped_lines = 0
    
    for i, line in enumerate(lines, 1):
        try:
            # Strip whitespace and convert to integer
            line = line.strip()
            if not line:
                skipped_lines += 1
                continue
                
            value = int(line)
            total += value
            processed_lines += 1
            
        except ValueError:
            if skip_invalid:
                skipped_lines += 1
                continue
            else:
                raise ValueError(f"Line {i} contains non-numeric data: '{line}'")
    
    return {
        "total": total,
        "processed_lines": processed_lines,
        "skipped_lines": skipped_lines
    }
                    

Exercise 2: Building a Robust Application

In this exercise, you'll build a more complex application that demonstrates multiple error handling best practices.

Task Description

Create a data processing pipeline that:

  1. Reads data from a CSV file
  2. Processes and validates the data
  3. Writes the results to an output file
  4. Properly handles all potential errors

The CSV file contains sales data with columns: date, product_id, quantity, price.

The application should calculate total sales by product and output the results.

# File: exercises/application_exercise.py

# Your task: Implement a robust sales data processor
# - Use proper exception handling throughout
# - Create custom exceptions if appropriate
# - Ensure all resources are properly cleaned up
# - Provide meaningful error messages
# - Include input validation
# - Add logging for operations and errors
                    

Example Approach

An effective solution would include:

  • A custom exception hierarchy for different error types
  • Context managers for file handling
  • Specific error handling for different failure scenarios
  • Input validation and data type checking
  • Detailed logging for operational visibility
  • Clear and informative error messages

Summary: Key Takeaways

  • Be specific about which exceptions you catch - Catch only the exceptions you can handle properly, and be as specific as possible.
  • Provide meaningful error messages - Error messages should explain what went wrong and, when appropriate, suggest how to fix it.
  • Fail fast with input validation - Detect and report errors as early as possible to isolate problems and make debugging easier.
  • Always clean up resources - Use context managers (with statement) to ensure proper resource cleanup, even when exceptions occur.
  • Don't suppress exceptions without good reason - Only catch exceptions when you have a specific recovery action to take.
  • Use custom exceptions for domain-specific errors - Create custom exception classes for errors that are specific to your application domain.
  • Translate low-level exceptions to application-specific ones - Catch technical exceptions and raise more meaningful ones in your application's context.
  • Include proper exception chaining - Use raise ... from ... to preserve the original exception context when translating exceptions.
  • Implement consistent error handling patterns - Use consistent patterns like error handling middleware for a unified approach across your application.
  • Consider the user experience - Design error handling with both end users and developers in mind, providing appropriate information to each.

By applying these best practices, you'll create more robust, maintainable, and user-friendly applications that handle errors gracefully and provide clear guidance when things go wrong.