Custom Exceptions and Error Hierarchies
Custom Exceptions and Error Hierarchies
After reading this chapter, you will design domain-specific exception hierarchies inheriting from Exception, carry structured diagnostic payloads inside custom exception objects, use explicit exception chaining (raise ... from err), and suppress noisy internal tracebacks using from None.
Mental model
Clean architectures do not let low-level internal exceptions (such as sqlite3.OperationalError or urllib3.exceptions.ProtocolError) leak out to callers. Instead, systems translate low-level faults into domain-specific exceptions.
Organizing exceptions into an inheritance hierarchy allows callers to catch either general errors or specific failure modes:
Domain Error Hierarchy:
Exception (Python standard base)
│
▼
StorageError (Base exception for storage subsystem)
├── ConnectionTimeoutError
├── AuthenticationFailedError
└── RecordNotFoundError
└── UserNotFoundError (Specialized subtype)
A caller can catch StorageError to handle any storage subsystem failure, or catch RecordNotFoundError specifically to initiate a creation workflow.
Exception Chaining (__cause__ vs __context__)
Python explicitly tracks why an exception occurred using chaining:
raise NewError from original_error: Sets__cause__, explicitly documenting thatNewErrorwas directly triggered byoriginal_error.raise NewError from None: Suppresses the prior traceback, hiding internal implementation details from callers.
Minimal example
Save as custom_error_hierarchy.py:
# custom_error_hierarchy.py
class CloudServiceError(Exception):
"""Base exception for all cloud provider interactions."""
class RateLimitExceededError(CloudServiceError):
"""Raised when API quotas are breached."""
def __init__(self, service: str, retry_after: int, quota: int) -> None:
super().__init__(f"Rate limit exceeded for {service}. Retry after {retry_after}s.")
self.service = service
self.retry_after = retry_after
self.quota = quota
def call_upstream_api(request_count: int) -> None:
if request_count > 100:
raise RateLimitExceededError(service="compute-api", retry_after=60, quota=100)
print(f"API call successful (request count: {request_count}).")
def main() -> None:
try:
call_upstream_api(150)
except RateLimitExceededError as exc:
print(f"Caught specific rate limit: {exc}")
print(f" -> Service: {exc.service} | Backoff: {exc.retry_after}s | Max Quota: {exc.quota}")
except CloudServiceError as exc:
print(f"Caught general cloud error: {exc}")
if __name__ == "__main__":
main()Run via uv run python custom_error_hierarchy.py:
Caught specific rate limit: Rate limit exceeded for compute-api. Retry after 60s.
-> Service: compute-api | Backoff: 60s | Max Quota: 100
Worked examples
Case 1: Exception Chaining with raise ... from err
When translating low-level networking errors into high-level domain errors, from err links the underlying cause so that debugging logs preserve the original root-cause traceback:
# chained_translation.py
import socket
class DatabaseConnectionError(Exception):
"""Domain exception representing storage cluster failure."""
def ping_db_node(host: str, port: int) -> None:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.01)
try:
# Intentionally connect to an unreachable test address
s.connect((host, port))
except (socket.timeout, OSError) as original_err:
# Wrap the low-level socket failure into our domain exception
raise DatabaseConnectionError(f"Failed connecting to database at {host}:{port}") from original_err
finally:
s.close()
if __name__ == "__main__":
try:
ping_db_node("192.0.2.1", 5432) # TEST-NET-1 unroutable address
except DatabaseConnectionError as err:
print(f"Domain Error: {err}")
print(f"Root Cause: {type(err.__cause__).__name__}: {err.__cause__}")Run:
uv run python chained_translation.pyOutput:
Domain Error: Failed connecting to database at 192.0.2.1:5432
Root Cause: TimeoutError: timed out
In the full traceback, Python prints:
The above exception was the direct cause of the following exception:
DatabaseConnectionError: Failed connecting to database at 192.0.2.1:5432
Case 2: Suppressing Internal Noise with from None
When building command-line utilities or clean public libraries, internal library tracebacks create confusing noise for end users. raise ... from None severs the link:
# clean_cli_error.py
import json
class ConfigurationParseError(Exception):
"""Raised when application configuration file is malformed."""
def load_system_config(raw_json: str) -> dict[str, str]:
try:
return json.loads(raw_json)
except json.JSONDecodeError as decode_err:
# Suppress JSONDecodeError traceback and present a clean error
raise ConfigurationParseError(
f"Config file syntax error at line {decode_err.lineno}, column {decode_err.colno}"
) from None
if __name__ == "__main__":
bad_config = '{"version": 2, "servers": ["app1", "app2", ]}'
try:
load_system_config(bad_config)
except ConfigurationParseError as err:
print(f"CLI Error: {err}")
print(f"Has chained cause? {err.__cause__ is not None}")Run:
uv run python clean_cli_error.pyOutput:
CLI Error: Config file syntax error at line 1, column 44
Has chained cause? False
Pitfalls
Pitfall 1: Inheriting from BaseException instead of Exception
Inheriting from BaseException causes custom errors to bypass standard except Exception: handlers:
# THE BUG:
class CustomError(BaseException): # NEVER inherit directly from BaseException!
pass
# THE FIX:
class CustomError(Exception): # ALWAYS inherit from Exception
passBaseException is reserved exclusively for Python’s core system events: SystemExit, KeyboardInterrupt, and GeneratorExit.
Pitfall 2: Overly Flat or Monolithic Exceptions
Creating a single AppError and using string matching to determine what happened (if "timeout" in str(e): ...) defeats Python’s type-based exception handling. Always create a base exception class and subclass it for distinct failure modes.
Exercises
- Design an exception hierarchy for an authentication service with a base
AuthError, and subclassesInvalidCredentialsError,TokenExpiredError, andAccountLockedError. - Write a custom exception
HTTPResponseErrorthat acceptsstatus_code: intandresponse_body: stras constructor arguments and exposes them as read-only properties. - Simulate catching a
FileNotFoundErrorand wrapping it in a customConfigFileMissingErrorusingraise ... from err. - Inspect the
__context__attribute of an exception raised inside anexceptblock without an explicitfrom.
Further reading
- PEP 3134: Exception Chaining and Embedded Tracebacks.
- Python Tutorial: User-defined Exceptions.
- Python Standard Library:
exceptionshierarchy.