An Introduction to the Art of Computer Programming Using Python in the Age of Generative AI

X. Error and Exception Handling

Errors should never pass silently.
Unless explicitly silenced.
Tim Peters, Zen of Python

Introduction to Error Handling

Error handling ensures that your program can respond gracefully to unexpected situations, such as invalid user input, missing files, or network timeouts. In Python, errors are communicated via exceptions. When an error arises, Python creates an exception object, interrupting the normal program flow. By handling exceptions proactively, you can prevent crashes and improve reliability.

Whether you’re developing simple scripts or large-scale applications, robust exception handling is important. It helps you troubleshoot issues faster and provides a better user experience by offering clear error messages or fallback actions instead of abrupt failures.

Basic Exception Handling

The try and except keywords are used to catch and manage exceptions. If an error occurs within the try block, Python checks the matching except block for that specific exception type, allowing the program to continue.


try:
    # Attempting to divide by zero causes a crash
    result = 10 / 0
except ZeroDivisionError:
    # This block runs only if the error occurs
    print("Error: Cannot divide by zero.")
    result = None

print(f"Result: {result}")
        

In this example, dividing by zero raises a ZeroDivisionError. The except block catches it, assigns a fallback value, and prevents a crash.

Python Philosophy: EAFP vs. LBYL

In many languages, the standard approach is "Look Before You Leap" (LBYL)—checking conditions before performing an action (e.g., if file_exists: open()).

However, Python favors EAFP: "Easier to Ask for Forgiveness than Permission." This means you assume the operation will succeed and capture the error if it fails. This style is often cleaner, faster, and avoids race conditions.


data = {"name": "Alice"}

# LBYL (Look Before You Leap)
if "age" in data:
    print(data["age"])
else:
    print("Age missing (LBYL)")

# EAFP (Pythonic)
try:
    print(data["age"])
except KeyError:
    print("Age missing (EAFP)")
        

Handling Multiple Exceptions

A single try block can have multiple except blocks to handle different types of errors. This lets you tailor your response according to the specific problem.

It is best practice to catch specific exceptions first (like IndexError) and generic exceptions (like Exception) last.


try:
    my_list = [1, 2, 3]
    # Trying to access index 5, which doesn't exist
    result = my_list[5]
except IndexError:
    result = "Error: List index out of range."
except Exception as e:
    # A catch-all for any other unforeseen errors
    result = f"Unexpected error: {e}"

print(result)
        
Warning: The Bare 'Except'
Avoid using a bare except: (without specifying an error type). This catches everything, including system exit signals and keyboard interrupts (Ctrl+C), making it impossible to stop your program. Always use at least except Exception: if you want to catch generic errors.

The Else and Finally Clauses

Python provides optional else and finally blocks to further refine error handling:


try:
    result = 10 / 2
except ZeroDivisionError:
    print("Divided by zero!")
else:
    print(f"Division successful: {result}")
finally:
    print("Cleanup: This runs no matter what.")
        

Defensive Programming: Assertions

While try/except handles runtime errors (like missing files), Assertions are used to catch internal logic errors during development. An assertion checks if a condition is true; if not, it crashes the program with an AssertionError. This is widely used in Data Science to ensure data is in the correct shape before processing.


def calculate_discount(price, discount):
    # Ensure inputs are logical before proceeding
    assert price >= 0, "Price cannot be negative"
    assert 0 <= discount <= 1, "Discount must be between 0 and 1"
    return price * (1 - discount)

try:
    print(f"Price: {calculate_discount(100, 1.5)}")
except AssertionError as e:
    print(f"Logic Error: {e}")
        

Raising Exceptions

You can use the raise statement to manually throw an exception. This is useful if you want to signal a specific error condition or enforce certain assumptions in your code.


def divide(a, b):
    if b == 0:
        # We manually raise an error to stop execution
        raise ValueError("You can't divide by zero!")
    return a / b

try:
    result = divide(10, 0)
except ValueError as ve:
    result = f"Caught error: {ve}"

print(result)
        

By raising a ValueError, you clearly highlight the problematic condition. This approach makes the code’s intent more obvious to anyone reading or using this function.

Generative AI Insight: API Resilience
When building AI applications, you rely on external APIs (like OpenAI or Anthropic). These often fail due to Rate Limits (too many requests) or Server Overload (Error 500). Robust AI code uses try-except blocks to catch these specific network errors and implement retry logic (waiting a few seconds before trying again) rather than crashing the application.

Custom Exception Classes

For more complex applications, you may define your own exceptions that extend Python’s built-in Exception class. Custom exceptions help you handle domain-specific errors more precisely.


# Define a custom exception
class NegativeValueError(Exception):
    pass

def sqrt(value):
    if value < 0:
        raise NegativeValueError("Cannot compute square root of negative number!")
    return value ** 0.5

try:
    result = sqrt(-9)
except NegativeValueError as nve:
    result = f"Custom Error: {nve}"

print(result)
        

Logging Exceptions

When exceptions occur, it’s often helpful to log them for troubleshooting or auditing. Python’s logging module provides a consistent way to track errors.


import logging

# Configure basic logging
logging.basicConfig(level=logging.ERROR)

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        # exc_info=True includes the traceback (stack trace) in the log
        logging.error("Attempted to divide by zero.", exc_info=True)
        return "undefined"

result = divide(10, 0)
print(f"Result: {result}")
        

Prompting Generative AI for Meaningful Error Handling

Large Language Models (LLMs) can help you design robust error handling approaches. By outlining the kinds of problems you anticipate—like file system issues, network timeouts, or user input validation—you can request example code that adheres to Python best practices.

Example Prompt:
Generate a Python function that reads from a file and handles file-related errors gracefully. Include specific handling for FileNotFoundError and PermissionError. Additionally, implement a "Retry" mechanism using a loop that attempts to read the file 3 times before giving up.

Resulting AI-generated code:


import time

def read_file_with_retry(file_path, retries=3):
    for attempt in range(1, retries + 1):
        try:
            # 'with' automatically closes the file (Cleanup)
            with open(file_path, 'r') as file:
                return file.read()
        except FileNotFoundError:
            return "Error: File not found (No retry for missing file)."
        except PermissionError:
            return "Error: Permission denied."
        except Exception as e:
            if attempt < retries:
                print(f"Attempt {attempt} failed ({e}). Retrying...")
                time.sleep(1) # Wait 1 second before retrying
            else:
                return f"Failed after {retries} attempts. Last error: {e}"

# Example usage with a non-existent file
print(read_file_with_retry("ghost_file.txt"))
        

You can adapt AI-generated snippets to your specific needs. The Retry Loop shown above is a standard pattern in AI engineering for dealing with flaky network requests.

Back to Home