Custom Exceptions and Error Hierarchies

Updated

September 7, 2026

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 that NewError was directly triggered by original_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.py

Output:

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.py

Output:

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
    pass

BaseException 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

  1. Design an exception hierarchy for an authentication service with a base AuthError, and subclasses InvalidCredentialsError, TokenExpiredError, and AccountLockedError.
  2. Write a custom exception HTTPResponseError that accepts status_code: int and response_body: str as constructor arguments and exposes them as read-only properties.
  3. Simulate catching a FileNotFoundError and wrapping it in a custom ConfigFileMissingError using raise ... from err.
  4. Inspect the __context__ attribute of an exception raised inside an except block without an explicit from.

Further reading

  • PEP 3134: Exception Chaining and Embedded Tracebacks.
  • Python Tutorial: User-defined Exceptions.
  • Python Standard Library: exceptions hierarchy.