Designing Custom Exceptions
Building semantic error hierarchies for professional libraries. From Inheritance to Chaining.
1. The Big Idea (ELI5)
👶 Explain Like I'm 10: Custom Warning Signs
Imagine driving a car. The dashboard lights up.
- Standard Error (`Exception`): A generic red light just says "ERROR". Is it the engine? The tire? The door? You don't know. You have to stop everything.
- Custom Exception (`EngineOverheatError`): A specific symbol lights up. You know exacty what's wrong. You can decide if you need to stop immediately or if you can drive to the nearest garage.
- The Goal: Custom exceptions give the caller of your code the power to make specific decisions based on exactly what failed.
2. The Syntax: Inheriting from Exception
A custom exception is just a normal class that inherits from `Exception`. You can add method, properties, and custom logic to it.
class NegativeBalanceError(Exception):
"""Raised when a withdrawal is larger than the balance."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
# Pass a nice message to the parent class
super().__init__(f"Cannot withdraw {amount}. Balance is only {balance}.")
# Usage
try:
raise NegativeBalanceError(100, 500)
except NegativeBalanceError as e:
print(e) # Cannot withdraw 500. Balance is only 100.
print(e.balance) # 100 (We can access the data programmatically!)3. Best Practice: The Library Base Exception
When writing a library (e.g., `requests`, `pandas`), you should never let your internal implementation details leak out.Always create a base exception class for your project.
# 1. The Base Class
class FileFusionError(Exception):
"""Base class for all exceptions in this module."""
pass
# 2. Specific Errors inherit from the Base
class ConnectionError(FileFusionError):
pass
class AuthError(FileFusionError):
pass
# 3. Usage for the User
try:
file_fusion.connect()
except FileFusionError:
# Catches ConnectionError, AuthError, and any future errors
# WITHOUT catching ValueError (which might be a bug in their own code)
print("Something went wrong with FileFusion!")Anti-Pattern: Never inherit from `BaseException`. It skips the normal exception hierarchy and might prevent users from stopping your code with Ctrl+C.
4. Deep Dive: Exception Chaining (`raise from`)
Sometimes you catch an error (like `KeyError`) but want to re-raise it as a semantic error (like `ConfigError`). You must preserve the original traceback so debugging isn't impossible.
class ConfigError(Exception):
pass
def load_config(key):
try:
data = {"host": "localhost"}
return data[key]
except KeyError as e:
# ⌠Bad: raise ConfigError("Missing Key") -> Loses the original error
# ✅ Good: Chain the exception
raise ConfigError(f"Configuration missing: {key}") from e
# Traceback will show:
# KeyError: 'port'
# The above exception was the direct cause of the following exception:
# ConfigError: Configuration missing: portThe `from e` syntax attaches the original exception to the `__cause__` attribute of the new exception. Similary, explicit suppression uses `from None`.
5. Attaching Payload and State
Exceptions are objects. Use this! Instead of parsing error string messages (flaky), attach structured data to the exception instance.
class APILimitError(Exception):
def __init__(self, limit, reset_time):
self.limit = limit
self.reset_time = reset_time # Unix timestamp
super().__init__(f"Rate limit of {limit} exceeded.")
try:
call_api()
except APILimitError as e:
import time
wait_time = e.reset_time - time.time()
print(f"Sleeping for {wait_time} seconds...")
time.sleep(wait_time)6. When NOT to use Custom Exceptions
Don't reinvent the wheel. If a built-in exception fits, use it.
- If a function gets the wrong argument type? Use `TypeError`.
- If the value is invalid (e.g., -1 for age)? Use `ValueError`.
- If a file is missing? Use `FileNotFoundError`.
Only create custom exceptions when the caller needs to distinguish this specific error from others programmatically.