Python Basics — Error Handling Exercises

7 minute read

Practice Exercises — Error Handling

Test your understanding of exceptions, try/except, raising errors, custom exceptions, and debugging.


Exercise 1: Safe Calculator

Write a safe_calculate(expression) function that takes a string like "10 / 3" and returns the result. Handle all possible errors gracefully: invalid operators, division by zero, non-numeric values.

Test with: "10 + 5", "10 / 0", "abc + 3", "10 % 3", "hello"

Show Solution
def safe_calculate(expression):
    """Evaluate a simple math expression safely."""
    valid_operators = {"+", "-", "*", "/"}
    
    try:
        parts = expression.split()
        
        if len(parts) != 3:
            raise ValueError(f"Expected 'num op num', got: '{expression}'")
        
        left_str, operator, right_str = parts
        
        if operator not in valid_operators:
            raise ValueError(f"Invalid operator: '{operator}' (use +, -, *, /)")
        
        left = float(left_str)
        right = float(right_str)
        
        if operator == "+":
            return left + right
        elif operator == "-":
            return left - right
        elif operator == "*":
            return left * right
        elif operator == "/":
            if right == 0:
                raise ZeroDivisionError("Cannot divide by zero")
            return left / right
    
    except ValueError as e:
        return f"Error: {e}"
    except ZeroDivisionError as e:
        return f"Error: {e}"

# Test
test_cases = ["10 + 5", "10 / 0", "abc + 3", "10 % 3", "hello"]
for expr in test_cases:
    result = safe_calculate(expr)
    print(f"  {expr!r:20} → {result}")

Output:

  '10 + 5'             → 15.0
  '10 / 0'             → Error: Cannot divide by zero
  'abc + 3'            → Error: could not convert string to float: 'abc'
  '10 % 3'             → Error: Invalid operator: '%' (use +, -, *, /)
  'hello'              → Error: Expected 'num op num', got: 'hello'

Exercise 2: Retry Decorator

Write a retry function that takes a function and retries it up to max_attempts times if it raises an exception. After all attempts fail, raise the last exception.

Show Solution
import time
import random

def retry(func, max_attempts=3, delay=1):
    """Retry a function up to max_attempts times."""
    last_exception = None
    
    for attempt in range(1, max_attempts + 1):
        try:
            result = func()
            print(f"  ✅ Attempt {attempt}: Success!")
            return result
        except Exception as e:
            last_exception = e
            print(f"  ❌ Attempt {attempt}: {type(e).__name__}: {e}")
            if attempt < max_attempts:
                print(f"     Retrying in {delay}s...")
                time.sleep(delay)
    
    raise last_exception


# Test with a flaky function
def flaky_api_call():
    """Simulates an unreliable API that fails 70% of the time."""
    if random.random() < 0.7:
        raise ConnectionError("Server unavailable")
    return {"status": "ok", "data": [1, 2, 3]}

print("Testing retry mechanism:")
try:
    result = retry(flaky_api_call, max_attempts=5, delay=0.5)
    print(f"  Final result: {result}")
except ConnectionError:
    print("  All attempts failed!")

Exercise 3: Custom Exception Hierarchy

Create an exception hierarchy for a banking application:

  • BankError (base)
    • AccountError: AccountNotFoundError, AccountLockedError
    • TransactionError: InsufficientFundsError, DailyLimitError

Then write a transfer(from_acc, to_acc, amount) function that raises appropriate exceptions.

Show Solution
# Exception hierarchy
class BankError(Exception):
    """Base exception for banking operations."""
    pass

class AccountError(BankError):
    """Account-related errors."""
    pass

class AccountNotFoundError(AccountError):
    """Account does not exist."""
    def __init__(self, account_id):
        self.account_id = account_id
        super().__init__(f"Account '{account_id}' not found")

class AccountLockedError(AccountError):
    """Account is locked."""
    def __init__(self, account_id, reason="security"):
        self.account_id = account_id
        self.reason = reason
        super().__init__(f"Account '{account_id}' is locked ({reason})")

class TransactionError(BankError):
    """Transaction-related errors."""
    pass

class InsufficientFundsError(TransactionError):
    """Not enough funds for the transaction."""
    def __init__(self, account_id, balance, amount):
        self.account_id = account_id
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Insufficient funds in '{account_id}': "
            f"balance=${balance:.2f}, requested=${amount:.2f}"
        )

class DailyLimitError(TransactionError):
    """Daily transaction limit exceeded."""
    def __init__(self, limit, attempted):
        self.limit = limit
        self.attempted = attempted
        super().__init__(
            f"Daily limit exceeded: limit=${limit:.2f}, attempted=${attempted:.2f}"
        )


# Bank simulation
accounts = {
    "ACC001": {"name": "Alice", "balance": 5000.00, "locked": False},
    "ACC002": {"name": "Bob", "balance": 1200.00, "locked": False},
    "ACC003": {"name": "Carol", "balance": 800.00, "locked": True},
}
DAILY_LIMIT = 10000.00

def transfer(from_id, to_id, amount):
    """Transfer money between accounts."""
    # Validate accounts exist
    if from_id not in accounts:
        raise AccountNotFoundError(from_id)
    if to_id not in accounts:
        raise AccountNotFoundError(to_id)
    
    from_acc = accounts[from_id]
    to_acc = accounts[to_id]
    
    # Check locks
    if from_acc["locked"]:
        raise AccountLockedError(from_id)
    if to_acc["locked"]:
        raise AccountLockedError(to_id, "receiving account locked")
    
    # Check daily limit
    if amount > DAILY_LIMIT:
        raise DailyLimitError(DAILY_LIMIT, amount)
    
    # Check funds
    if from_acc["balance"] < amount:
        raise InsufficientFundsError(from_id, from_acc["balance"], amount)
    
    # Execute transfer
    from_acc["balance"] -= amount
    to_acc["balance"] += amount
    print(f"✅ Transferred ${amount:.2f}: {from_id} → {to_id}")


# Test cases
test_transfers = [
    ("ACC001", "ACC002", 500),     # ✅ Success
    ("ACC999", "ACC001", 100),     # ❌ AccountNotFoundError
    ("ACC001", "ACC003", 200),     # ❌ AccountLockedError
    ("ACC002", "ACC001", 5000),    # ❌ InsufficientFundsError
    ("ACC001", "ACC002", 50000),   # ❌ DailyLimitError
]

for from_id, to_id, amount in test_transfers:
    try:
        transfer(from_id, to_id, amount)
    except BankError as e:
        print(f"❌ {type(e).__name__}: {e}")

Exercise 4: Input Validator

Write a get_valid_input() function that keeps prompting the user until they enter a valid value. Support different types: integer, float, and choice from a list.

Show Solution
def get_valid_input(prompt, input_type="str", choices=None, min_val=None, max_val=None):
    """
    Get validated input from the user.
    
    Args:
        prompt: Message to display
        input_type: "str", "int", or "float"
        choices: List of valid choices (for str type)
        min_val: Minimum value (for int/float)
        max_val: Maximum value (for int/float)
    """
    while True:
        try:
            raw = input(prompt)
            
            if input_type == "int":
                value = int(raw)
            elif input_type == "float":
                value = float(raw)
            else:
                value = raw.strip()
            
            # Validate choices
            if choices and value not in choices:
                print(f"  Invalid choice. Choose from: {choices}")
                continue
            
            # Validate range
            if min_val is not None and value < min_val:
                print(f"  Value must be at least {min_val}")
                continue
            if max_val is not None and value > max_val:
                print(f"  Value must be at most {max_val}")
                continue
            
            return value
        
        except ValueError:
            print(f"  Please enter a valid {input_type}")
        except KeyboardInterrupt:
            print("\n  Input cancelled.")
            return None

# Test (uncomment to try interactively)
# age = get_valid_input("Enter your age: ", "int", min_val=0, max_val=150)
# print(f"Age: {age}")

# color = get_valid_input("Pick a color: ", "str", choices=["red", "green", "blue"])
# print(f"Color: {color}")

# Example of the function structure for non-interactive testing:
print("get_valid_input() is ready for interactive use!")
print("It handles: ValueError, range validation, choice validation, KeyboardInterrupt")

Exercise 5: Debug This Code

The following code has 5 bugs. Find and fix them all.

def analyze_scores(students):
    results = []
    
    for student in students:
        name = student["name"]
        scores = student["scores"]
        
        average = sum(scores) / len(scores)
        
        if average >= 90:
            grade = "A"
        elif average >= 80:
            grade = "B"
        elif average >= 70:
            grade = "C"
        else:
            grade = "F"
        
        results.append({"name": name, "average": average, "grade": grade})
    
    best = max(results, key=lambda r: r["average"])
    print(f"Best student: {best['name']} ({best['average']:.1f})")
    
    return results


# Test data
students = [
    {"name": "Alice", "scores": [92, 88, 95]},
    {"name": "Bob", "scores": []},
    {"name": "Carol", "scores": [78, 82, "75"]},
    {"name": "Dave"},
    {"name": None, "scores": [90, 85]},
]

results = analyze_scores(students)
for r in results:
    print(f"{r['name']}: {r['average']:.1f} ({r['grade']})")
Show Solution

Bugs found:

  1. Bug 1: Bob has empty scores → ZeroDivisionError on len(scores) = 0
  2. Bug 2: Carol’s score "75" is a string → sum() fails with TypeError
  3. Bug 3: Dave has no "scores" key → KeyError
  4. Bug 4: None name → print works but is misleading
  5. Bug 5: If results is empty, max() raises ValueError

Fixed version:

def analyze_scores(students):
    """Analyze student scores with proper error handling."""
    results = []
    
    for i, student in enumerate(students):
        try:
            # Bug 4: Handle missing or invalid name
            name = student.get("name")
            if not name:
                print(f"  ⚠️ Student #{i+1}: Missing name, skipping")
                continue
            
            # Bug 3: Handle missing scores key
            if "scores" not in student:
                print(f"  ⚠️ {name}: No scores data, skipping")
                continue
            
            scores = student["scores"]
            
            # Bug 1: Handle empty scores
            if not scores:
                print(f"  ⚠️ {name}: Empty scores list, skipping")
                continue
            
            # Bug 2: Ensure all scores are numbers
            clean_scores = []
            for s in scores:
                try:
                    clean_scores.append(float(s))
                except (ValueError, TypeError):
                    print(f"  ⚠️ {name}: Invalid score '{s}', ignoring")
            
            if not clean_scores:
                print(f"  ⚠️ {name}: No valid scores, skipping")
                continue
            
            average = sum(clean_scores) / len(clean_scores)
            
            if average >= 90:
                grade = "A"
            elif average >= 80:
                grade = "B"
            elif average >= 70:
                grade = "C"
            else:
                grade = "F"
            
            results.append({"name": name, "average": average, "grade": grade})
        
        except Exception as e:
            print(f"  ❌ Unexpected error for student #{i+1}: {e}")
    
    # Bug 5: Handle empty results
    if results:
        best = max(results, key=lambda r: r["average"])
        print(f"\nBest student: {best['name']} ({best['average']:.1f})")
    else:
        print("\nNo valid results to analyze")
    
    return results


# Test
students = [
    {"name": "Alice", "scores": [92, 88, 95]},
    {"name": "Bob", "scores": []},
    {"name": "Carol", "scores": [78, 82, "75"]},
    {"name": "Dave"},
    {"name": None, "scores": [90, 85]},
]

results = analyze_scores(students)
print()
for r in results:
    print(f"{r['name']}: {r['average']:.1f} ({r['grade']})")

Output:

  ⚠️ Bob: Empty scores list, skipping
  ⚠️ Dave: No scores data, skipping
  ⚠️ Student #5: Missing name, skipping

Best student: Alice (91.7)

Alice: 91.7 (A)
Carol: 78.3 (C)

Scoring Guide

Score Level
5/5 Excellent — Exception handling expert
3-4/5 Good — Solid error handling skills
1-2/5 Keep practicing — Review try/except and custom exceptions

Next chapter: Practice & Next Steps →

Updated: