Error Handling - Graceful Recovery from Mistakes

Error handling is like having a backup plan. If it rains during a picnic (error), you move indoors (handle it) instead of getting wet (crashing). Your program should handle unexpected situations without crashing.
**Errors (exceptions)** happen when Python encounters a situation it cannot handle. For example, dividing by zero, trying to open a file that doesn't exist, or converting a letter to a number. Without error handling, these errors crash your program and show a scary red error message. With error handling, you can anticipate problems and respond gracefully.

**Basic `try`‑`except` block:**
```python
try:
# code that might cause an error
risky_operation()
except SomeErrorType:
# code to run if that specific error occurs
```

**Common built‑in exceptions:**
- `ValueError` – when a function receives an argument of the right type but inappropriate value (e.g., `int("abc")`).
- `ZeroDivisionError` – division by zero.
- `TypeError` – operation applied to wrong type (e.g., `"5" + 3`).
- `FileNotFoundError` – trying to open a file that doesn't exist.
- `IndexError` – list index out of range.
- `KeyError` – dictionary key not found.
- `NameError` – variable not defined.

**Handling multiple exceptions:**
You can have several `except` blocks, each for a different error type. Python checks them in order.
```python
try:
num = int(input("Enter number: "))
result = 10 / num
except ValueError:
print("Not a valid number!")
except ZeroDivisionError:
print("Cannot divide by zero!")
```

**Getting error details:**
Use `as` to capture the exception object.
```python
except ValueError as e:
print(f"Error: {e}")
```

**`else` block:**
Runs **only if** no exception occurred in the `try` block. Useful for code that should execute only when the risky operation succeeds.

**`finally` block:**
Runs **always**, whether an exception occurred or not. Typically used for cleanup (closing files, releasing resources).
```python
try:
file = open("data.txt")
data = file.read()
except FileNotFoundError:
print("File missing")
else:
print("File read successfully")
finally:
file.close() # always runs
```

**Raising your own exceptions:**
Use `raise` to trigger an exception when your code detects a problem.
```python
if age < 0:
raise ValueError("Age cannot be negative")
```

**Creating custom exception classes:**
You can define your own exception by inheriting from `Exception`.
```python
class NegativeAgeError(Exception):
pass
```

**When to use error handling:**
- User input (conversion to numbers, file names).
- File operations (missing files, permission errors).
- Network requests (timeouts, connection errors).
- Parsing external data.
- Any operation that depends on external resources.

**Common mistakes:**
- Using a bare `except:` (catches everything, including `KeyboardInterrupt` and `SystemExit`). Better to catch specific exceptions.
- Putting too much code inside `try` – should only wrap the risky part.
- Catching an exception and doing nothing (swallowing errors) – at least log or print a message.
- Using `except Exception` is generally fine, but avoid bare `except:`.

**Best practices:**
- Be specific – catch only the errors you expect.
- Keep `try` blocks short.
- Use `else` for code that depends on the success of the `try` block.
- Use `finally` for cleanup.
- Log errors for debugging.
- Let unexpected exceptions crash (don't catch them) – they reveal bugs.

**Practice exercises:**
1. Write a program that asks for two numbers and divides them. Handle `ZeroDivisionError` and `ValueError` separately.
2. Write a function that reads a file and returns its content. Handle `FileNotFoundError` and return an empty string instead of crashing.
3. Create a custom exception `InsufficientFundsError`. Write a `BankAccount` class with a `withdraw()` method that raises it if balance goes negative.
4. Use a `try`‑`except`‑`else`‑`finally` block to safely read a file and always print "Operation complete" at the end.
5. Write a loop that keeps asking for a valid integer until the user provides one (using `try`/`except`).

**Real‑world examples:**
- Web scraping: handle network errors and retry.
- Data processing: skip malformed rows instead of crashing the whole job.
- User‑facing applications: display friendly error messages instead of stack traces.
# ========== EXAMPLE 1: Basic Try-Except with Multiple Exception Types ==========
print("=== Example 1: Handling Division Errors ===")
try:
    number = int(input("Enter a number: "))
    result = 10 / number
    print(f"10 divided by {number} = {result}")
except ValueError:
    print("Error: That's not a valid number!")
except ZeroDivisionError:
    print("Error: Cannot divide by zero!")
except Exception as e:
    print(f"Unexpected error: {e}")
print()

# ========== EXAMPLE 2: Using Else and Finally Blocks ==========
print("=== Example 2: File Reading with Else and Finally ===")
filename = input("Enter filename to read: ")
file = None
try:
    file = open(filename, "r")
    content = file.read()
except FileNotFoundError:
    print(f"Error: File '{filename}' not found!")
else:
    # Runs only if no exception occurred
    print(f"\nFile contents:\n{content}")
finally:
    # Always runs (cleanup)
    if file:
        file.close()
        print("File closed.")
print()

# ========== EXAMPLE 3: Raising Custom Exceptions and Validation ==========
print("=== Example 3: Raising Exceptions and Age Validation ===")
# Custom exception
class NegativeAgeError(Exception):
    """Raised when age is negative"""
    pass

def check_age(age):
    if age < 0:
        raise NegativeAgeError("Age cannot be negative!")
    elif age < 18:
        return "Minor"
    elif age > 120:
        raise ValueError("Age seems unrealistic!")
    else:
        return "Adult"

# Test with a list of ages
test_ages = [15, 25, -5, 150]
for age in test_ages:
    try:
        category = check_age(age)
        print(f"Age {age}: {category}")
    except NegativeAgeError as e:
        print(f"Age {age}: Custom Error - {e}")
    except ValueError as e:
        print(f"Age {age}: ValueError - {e}")
    except Exception as e:
        print(f"Age {age}: Unexpected - {e}")

# Bonus: Input validation loop using try/except
print("\n--- Input Validation Loop ---")
while True:
    try:
        user_input = int(input("Enter a number (or 0 to exit): "))
        if user_input == 0:
            break
        print(f"You entered: {user_input}")
    except ValueError:
        print("That's not a number! Try again.")
print("Goodbye!")

→ Run this code interactively