Skip to main content
Advertisement

Custom Exceptions — Designing Custom Exception Class Patterns

Built-in exceptions alone are insufficient for clearly expressing domain-specific error conditions. Designing custom exception classes lets you communicate errors precisely and allows callers to handle them with fine-grained control.


The Simplest Custom Exception

class AppError(Exception):
"""Application base exception"""
pass

class UserNotFoundError(AppError):
"""When a user cannot be found"""
pass

# Usage
raise UserNotFoundError("User ID 42 not found.")

Even just pass is sufficient. The exception name itself communicates the meaning of the error.


Including Additional Attributes and Methods

Embedding structured information in exceptions lets callers handle errors more precisely.

class ValidationError(Exception):
"""Input validation failure"""

def __init__(
self,
field: str,
value: object,
message: str
) -> None:
self.field = field
self.value = value
self.message = message
# Initialize parent class
super().__init__(f"[{field}] {message} (received: {value!r})")

def to_dict(self) -> dict:
return {
"field": self.field,
"value": self.value,
"message": self.message,
}
try:
raise ValidationError("age", -5, "Age must be 0 or greater")
except ValidationError as e:
print(e) # [age] Age must be 0 or greater (received: -5)
print(e.field) # age
print(e.to_dict()) # {'field': 'age', 'value': -5, ...}

Overriding __str__ and __repr__

class HttpError(Exception):
"""HTTP error response"""

def __init__(self, status_code: int, detail: str) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(detail)

def __str__(self) -> str:
return f"HTTP {self.status_code}: {self.detail}"

def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"status_code={self.status_code!r}, "
f"detail={self.detail!r})"
)

err = HttpError(404, "Resource not found")
print(str(err)) # HTTP 404: Resource not found
print(repr(err)) # HttpError(status_code=404, detail='Resource not found')

Designing an Exception Inheritance Hierarchy

A good exception hierarchy reflects domain boundaries and error severity.

# ── Base exception ──────────────────────────────────────────────
class AppBaseError(Exception):
"""Root of all application exceptions"""
pass

# ── Domain-level classification ─────────────────────────────────
class DatabaseError(AppBaseError):
"""Database-related errors"""
pass

class NetworkError(AppBaseError):
"""Network-related errors"""
pass

class AuthError(AppBaseError):
"""Authentication/authorization errors"""
pass

class ValidationError(AppBaseError):
"""Data validation errors"""
pass

# ── Specific exceptions ──────────────────────────────────────────
class ConnectionError(DatabaseError):
"""DB connection failure"""
pass

class QueryError(DatabaseError):
"""SQL query error"""
def __init__(self, query: str, reason: str) -> None:
self.query = query
self.reason = reason
super().__init__(f"Query failed: {reason}\nQuery: {query}")

class TimeoutError(NetworkError):
"""Request timeout"""
def __init__(self, url: str, timeout: float) -> None:
self.url = url
self.timeout = timeout
super().__init__(f"Timeout ({timeout}s): {url}")

class UnauthorizedError(AuthError):
"""Authentication failure"""
pass

class ForbiddenError(AuthError):
"""Insufficient permissions"""
def __init__(self, resource: str, required_role: str) -> None:
self.resource = resource
self.required_role = required_role
super().__init__(
f"Accessing '{resource}' requires '{required_role}' role"
)

The hierarchy lets callers choose between specific and general exception handling.

def process():
raise QueryError("SELECT * FROM users", "Table not found")

try:
process()
except QueryError as e:
print(f"SQL error handling: {e.query}") # Specific handling
except DatabaseError:
print("General DB error handling") # Generic handling
except AppBaseError:
print("App error handling") # Top-level handling

Practical Example — API Error Class Hierarchy

An exception system suitable for a real REST API server.

from http import HTTPStatus
from typing import Any

class APIError(Exception):
"""Base class for API errors"""

status_code: int = 500
error_code: str = "INTERNAL_ERROR"

def __init__(
self,
message: str,
detail: Any = None,
headers: dict[str, str] | None = None,
) -> None:
self.message = message
self.detail = detail
self.headers = headers or {}
super().__init__(message)

def to_response(self) -> dict[str, Any]:
"""Return API error response dictionary"""
response: dict[str, Any] = {
"error": self.error_code,
"message": self.message,
}
if self.detail is not None:
response["detail"] = self.detail
return response

def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"status={self.status_code}, "
f"code={self.error_code!r}, "
f"message={self.message!r})"
)


class BadRequestError(APIError):
"""400 Bad Request"""
status_code = 400
error_code = "BAD_REQUEST"


class ValidationAPIError(BadRequestError):
"""400 — Input validation failure"""
error_code = "VALIDATION_ERROR"

def __init__(self, errors: list[dict[str, str]]) -> None:
self.errors = errors
super().__init__(
message="Input validation failed.",
detail=errors,
)

def to_response(self) -> dict[str, Any]:
resp = super().to_response()
resp["errors"] = self.errors
return resp


class NotFoundError(APIError):
"""404 Not Found"""
status_code = 404
error_code = "NOT_FOUND"

def __init__(self, resource: str, identifier: Any) -> None:
self.resource = resource
self.identifier = identifier
super().__init__(
message=f"{resource} '{identifier}' not found.",
detail={"resource": resource, "id": identifier},
)


class ConflictError(APIError):
"""409 Conflict"""
status_code = 409
error_code = "CONFLICT"


class RateLimitError(APIError):
"""429 Too Many Requests"""
status_code = 429
error_code = "RATE_LIMIT_EXCEEDED"

def __init__(self, retry_after: int) -> None:
self.retry_after = retry_after
super().__init__(
message="Request limit exceeded.",
headers={"Retry-After": str(retry_after)},
)


# FastAPI / Flask style exception handler
def handle_api_error(exc: APIError) -> dict[str, Any]:
return {
"status": exc.status_code,
"body": exc.to_response(),
"headers": exc.headers,
}
# Usage examples
def get_user(user_id: int) -> dict:
users = {1: {"name": "Alice"}}
if user_id not in users:
raise NotFoundError("User", user_id)
return users[user_id]

def create_user(data: dict) -> dict:
errors = []
if not data.get("name"):
errors.append({"field": "name", "message": "Required field"})
if not data.get("email"):
errors.append({"field": "email", "message": "Required field"})
if errors:
raise ValidationAPIError(errors)
return {"id": 42, **data}

# Handling
try:
user = get_user(99)
except NotFoundError as e:
response = handle_api_error(e)
print(response)
# {'status': 404, 'body': {'error': 'NOT_FOUND', ...}, 'headers': {}}

dataclass-based Exceptions (Python 3.10+)

from dataclasses import dataclass, field

@dataclass
class BusinessError(Exception):
"""Business logic error"""
code: str
message: str
context: dict = field(default_factory=dict)

def __post_init__(self):
# Initialize Exception (supports str(exception))
super().__init__(self.message)

def __str__(self) -> str:
return f"[{self.code}] {self.message}"

# Usage
raise BusinessError(
code="INSUFFICIENT_BALANCE",
message="Insufficient balance.",
context={"required": 10000, "available": 3000}
)

Expert Tips

Tip 1 — No need for from __future__ import annotations in exception classes

Exception classes themselves need to be evaluated at runtime, so PEP 563 lazy evaluation is not needed.

Tip 2 — Always expose a base exception if you're writing a library

# mylib/exceptions.py
class MyLibError(Exception):
"""Base class for all mylib exceptions"""
pass

# Users can catch all mylib exceptions at once
try:
mylib.do_something()
except mylib.MyLibError:
pass

Tip 3 — Write exception messages for developers

# Bad: vague message
raise ValueError("Error")

# Good: include cause, value, and resolution hint
raise ValueError(
f"Age value {age!r} is invalid. "
f"Enter an integer between 0 and 150."
)

Tip 4 — Automate exception registration with __init_subclass__

class RegisteredError(Exception):
_registry: dict[str, type] = {}

def __init_subclass__(cls, code: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if code:
RegisteredError._registry[code] = cls

class NotFoundError(RegisteredError, code="NOT_FOUND"):
pass

class AuthError(RegisteredError, code="UNAUTHORIZED"):
pass

# Look up exception class by code
exc_class = RegisteredError._registry["NOT_FOUND"]
raise exc_class("Not found")
Advertisement