Python Custom Exceptions Practice Problems & Exercises
Practice: Custom Exceptions
← Back to lessonEasy
Define a custom exception called ItemNotFoundError that inherits from Exception. Then write a function lookup_item that raises this exception when the given item ID is not in the inventory dictionary. Catch it and print the message.
class ItemNotFoundError(Exception):
pass
def lookup_item(inventory, item_id):
"""Look up an item by ID. Raise ItemNotFoundError if missing."""
if item_id not in inventory:
raise ItemNotFoundError(
"Item '" + item_id + "' not found in inventory"
)
return inventory[item_id]
# Test
inventory = {"widget-01": 10, "widget-02": 25}
try:
lookup_item(inventory, "widget-99")
except ItemNotFoundError as e:
print("Caught:", e)
print("Type check passed:", isinstance(e, Exception))Solution
class ItemNotFoundError(Exception):
pass
def lookup_item(inventory, item_id):
"""Look up an item by ID. Raise ItemNotFoundError if missing."""
if item_id not in inventory:
raise ItemNotFoundError(
"Item '" + item_id + "' not found in inventory"
)
return inventory[item_id]
inventory = {"widget-01": 10, "widget-02": 25}
try:
lookup_item(inventory, "widget-99")
except ItemNotFoundError as e:
print("Caught:", e)
print("Type check passed:", isinstance(e, Exception))
Key points:
class ItemNotFoundError(Exception): passis a complete, valid custom exception.- It inherits all of
Exception's machinery:args,__str__,__repr__. - Callers can now write
except ItemNotFoundErrorto catch only this specific error, not everyKeyErrororValueErrorin the stack. - The minimal form is enough when the message alone carries all needed context.
Expected Output
Caught: Item 'widget-99' not found in inventory\nType check passed: TrueHints
Hint 1: A minimal custom exception just needs `class YourError(Exception): pass` — one line of inheritance gives you a distinct catchable type.
Hint 2: Raise it with a descriptive message string: `raise ItemNotFoundError("Item 'widget-99' not found in inventory")`.
Create an InsufficientFundsError exception that stores account_id, amount, and balance as attributes, and also computes a shortfall attribute. Call super().__init__() with a formatted message.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds available balance."""
def __init__(self, account_id, amount, balance):
self.account_id = account_id
self.amount = amount
self.balance = balance
self.shortfall = amount - balance
super().__init__(
"Account '" + account_id + "': cannot withdraw "
+ format(amount, ".2f") + ", balance is "
+ format(balance, ".2f")
)
# Test
try:
raise InsufficientFundsError("ACC-042", 500.00, 200.00)
except InsufficientFundsError as e:
print("Error:", e)
print("Account:", e.account_id)
print("Shortfall:", format(e.shortfall, ".2f"))Solution
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds available balance."""
def __init__(self, account_id, amount, balance):
self.account_id = account_id
self.amount = amount
self.balance = balance
self.shortfall = amount - balance
super().__init__(
"Account '" + account_id + "': cannot withdraw "
+ format(amount, ".2f") + ", balance is "
+ format(balance, ".2f")
)
try:
raise InsufficientFundsError("ACC-042", 500.00, 200.00)
except InsufficientFundsError as e:
print("Error:", e)
print("Account:", e.account_id)
print("Shortfall:", format(e.shortfall, ".2f"))
Why this matters:
- The caller reads
e.account_idande.shortfallas structured data — no string parsing needed. super().__init__(message)ensuresstr(e)returns the human-readable message for logging.- The
shortfallis a computed attribute — the exception does the math so the caller does not have to. - This pattern separates machine-readable data (attributes) from human-readable output (
__str__).
Expected Output
Error: Account 'ACC-042': cannot withdraw 500.00, balance is 200.00\nAccount: ACC-042\nShortfall: 300.00Hints
Hint 1: Override `__init__` to accept `account_id`, `amount`, and `balance` as parameters, then store them as `self.account_id`, `self.amount`, `self.balance`.
Hint 2: Always call `super().__init__(message)` with the human-readable string so that `str(e)` works correctly in logs and print statements.
Create a NegativeAmountError that inherits from ValueError (not Exception). It should accept amount and field parameters. Verify that it can be caught as both NegativeAmountError and ValueError.
class NegativeAmountError(ValueError):
"""A monetary amount that must be positive is negative or zero."""
def __init__(self, amount, field="amount"):
self.amount = amount
self.field = field
super().__init__(
"'" + field + "' must be positive, got " + str(amount)
)
# Test
try:
raise NegativeAmountError(-29.99, field="price")
except NegativeAmountError as e:
print("Caught as NegativeAmountError:", e)
print("Also a ValueError:", isinstance(e, ValueError))
print("Field:", e.field)
print("Amount:", e.amount)Solution
class NegativeAmountError(ValueError):
"""A monetary amount that must be positive is negative or zero."""
def __init__(self, amount, field="amount"):
self.amount = amount
self.field = field
super().__init__(
"'" + field + "' must be positive, got " + str(amount)
)
try:
raise NegativeAmountError(-29.99, field="price")
except NegativeAmountError as e:
print("Caught as NegativeAmountError:", e)
print("Also a ValueError:", isinstance(e, ValueError))
print("Field:", e.field)
print("Amount:", e.amount)
When to subclass a built-in:
- Use this when your error IS a specialised form of a known category. A negative amount is a bad value, so
ValueErroris the correct parent. - Callers who write
except ValueErrorwill catchNegativeAmountErrorautomatically — this is correct behavior. - Callers who need precision can write
except NegativeAmountErrorfor targeted handling. - This mirrors Python's own design:
FileNotFoundErrorinherits fromOSError.
Expected Output
Caught as NegativeAmountError: 'price' must be positive, got -29.99\nAlso a ValueError: True\nField: price\nAmount: -29.99Hints
Hint 1: Inherit from `ValueError` instead of `Exception` — this makes your error catchable by both `except NegativeAmountError` and `except ValueError`.
Hint 2: Store `amount` and `field` as attributes, then pass the human message to `super().__init__()`.
Create an APIError exception with status_code, endpoint, and detail attributes. Implement both __str__ (human-friendly) and __repr__ (developer-friendly, looks like a constructor call). Verify both representations.
class APIError(Exception):
def __init__(self, status_code, endpoint, detail):
self.status_code = status_code
self.endpoint = endpoint
self.detail = detail
super().__init__(detail)
def __str__(self):
return (
"HTTP " + str(self.status_code)
+ " at " + self.endpoint
+ " — " + self.detail
)
def __repr__(self):
return (
"APIError("
+ "status_code=" + repr(self.status_code)
+ ", endpoint=" + repr(self.endpoint)
+ ", detail=" + repr(self.detail)
+ ")"
)
# Test
err = APIError(404, "/api/users/99", "User not found")
print("str:", str(err))
print("repr:", repr(err))
print("In list:", [err])Solution
class APIError(Exception):
def __init__(self, status_code, endpoint, detail):
self.status_code = status_code
self.endpoint = endpoint
self.detail = detail
super().__init__(detail)
def __str__(self):
return (
"HTTP " + str(self.status_code)
+ " at " + self.endpoint
+ " — " + self.detail
)
def __repr__(self):
return (
"APIError("
+ "status_code=" + repr(self.status_code)
+ ", endpoint=" + repr(self.endpoint)
+ ", detail=" + repr(self.detail)
+ ")"
)
err = APIError(404, "/api/users/99", "User not found")
print("str:", str(err))
print("repr:", repr(err))
print("In list:", [err])
__str__ vs __repr__ rules:
__str__is for humans: logs, user-facing messages,print(). It should be readable and informative.__repr__is for developers: debuggers, REPL, containers. It should be unambiguous and look like a constructor call.- When an object is inside a list, Python uses
__repr__, not__str__— that is why defining both matters. - If you only define one, prefer
__repr__— Python falls back to it forstr()when__str__is missing.
Expected Output
str: HTTP 404 at /api/users/99 — User not found\nrepr: APIError(status_code=404, endpoint='/api/users/99', detail='User not found')\nIn list: [APIError(status_code=404, endpoint='/api/users/99', detail='User not found')]Hints
Hint 1: `__str__` returns the human-readable message for `print()` and `str()`. `__repr__` returns the developer-readable representation used in logs and when the object appears inside containers.
Hint 2: A good `__repr__` looks like a constructor call: `ClassName(param=value, ...)` so a developer can reconstruct the object mentally.
Medium
Design a three-level exception hierarchy for a data pipeline library. Create: a base PipelineError, category exceptions SourceError and TransformError, and specific exceptions SourceConnectionError (with source_url and reason) and SchemaValidationError (with field, expected_type, and got_value). Show that a specific exception is caught by all its ancestors.
class PipelineError(Exception):
"""Base exception for all pipeline errors."""
pass
class SourceError(PipelineError):
"""Errors related to data sources."""
pass
class TransformError(PipelineError):
"""Errors during data transformation."""
pass
class SourceConnectionError(SourceError):
"""Cannot connect to a data source."""
def __init__(self, source_url, reason):
self.source_url = source_url
self.reason = reason
super().__init__(
"Cannot connect to '" + source_url + "': " + reason
)
class SchemaValidationError(TransformError):
"""Input data does not match expected schema."""
def __init__(self, field, expected_type, got_value):
self.field = field
self.expected_type = expected_type
self.got_value = got_value
super().__init__(
"Field '" + field + "': expected "
+ expected_type.__name__ + ", got "
+ type(got_value).__name__
)
# Test hierarchy
err = SourceConnectionError("postgres://db:5432", "connection refused")
try:
raise err
except SourceConnectionError as e:
print("Caught SourceConnectionError:", e)
print("URL:", e.source_url)
print("Caught broadly as PipelineError:", isinstance(e, PipelineError))
print("Caught broadly as SourceError:", isinstance(e, SourceError))Solution
class PipelineError(Exception):
"""Base exception for all pipeline errors."""
pass
class SourceError(PipelineError):
"""Errors related to data sources."""
pass
class TransformError(PipelineError):
"""Errors during data transformation."""
pass
class SourceConnectionError(SourceError):
"""Cannot connect to a data source."""
def __init__(self, source_url, reason):
self.source_url = source_url
self.reason = reason
super().__init__(
"Cannot connect to '" + source_url + "': " + reason
)
class SchemaValidationError(TransformError):
"""Input data does not match expected schema."""
def __init__(self, field, expected_type, got_value):
self.field = field
self.expected_type = expected_type
self.got_value = got_value
super().__init__(
"Field '" + field + "': expected "
+ expected_type.__name__ + ", got "
+ type(got_value).__name__
)
err = SourceConnectionError("postgres://db:5432", "connection refused")
try:
raise err
except SourceConnectionError as e:
print("Caught SourceConnectionError:", e)
print("URL:", e.source_url)
print("Caught broadly as PipelineError:", isinstance(e, PipelineError))
print("Caught broadly as SourceError:", isinstance(e, SourceError))
Hierarchy design principles:
PipelineError <-- catch-all for the library
├── SourceError <-- all source-related issues
│ ├── SourceConnectionError
│ └── SourceTimeoutError
├── TransformError <-- all transformation issues
│ ├── SchemaValidationError
│ └── DataRangeError
└── SinkError <-- all output issues
└── SinkWriteError
- One root per library — callers can
except PipelineErrorto catch everything from your library. - Category mid-level exceptions group related errors.
- Leaf exceptions carry specific structured attributes.
- This is the same pattern used by
requests, SQLAlchemy, and Pydantic.
Expected Output
Caught SourceConnectionError: Cannot connect to 'postgres://db:5432': connection refused\nURL: postgres://db:5432\nCaught broadly as PipelineError: True\nCaught broadly as SourceError: TrueHints
Hint 1: Start with a single base exception for the entire library (e.g., `PipelineError`). Then create category-level exceptions (`SourceError`, `TransformError`) that inherit from it. Finally, create specific leaf exceptions that inherit from the category.
Hint 2: Each level of the hierarchy lets callers choose their catch granularity: `except PipelineError` catches everything, `except SourceError` catches all source issues, `except SourceConnectionError` catches one specific scenario.
Write a get_user function that internally uses a dictionary lookup. When the key is missing, catch the KeyError and raise a custom UserServiceError chained from the original. Verify that __cause__ preserves the original exception.
class UserServiceError(Exception):
"""Error in the user service layer."""
def __init__(self, user_id, reason):
self.user_id = user_id
self.reason = reason
super().__init__(
"Failed to load user '" + str(user_id) + "': " + reason
)
def get_user(store, user_id):
"""Fetch a user from the store, wrapping internal errors."""
try:
return store[user_id]
except KeyError as e:
raise UserServiceError(
user_id, "user not found in store"
) from e
# Test
store = {"u-001": "Alice", "u-002": "Bob"}
try:
get_user(store, "u-404")
except UserServiceError as e:
print("Caught UserServiceError:", e)
print("Original cause:", repr(e.__cause__))
print("Chain preserved:", e.__cause__ is not None)Solution
class UserServiceError(Exception):
"""Error in the user service layer."""
def __init__(self, user_id, reason):
self.user_id = user_id
self.reason = reason
super().__init__(
"Failed to load user '" + str(user_id) + "': " + reason
)
def get_user(store, user_id):
"""Fetch a user from the store, wrapping internal errors."""
try:
return store[user_id]
except KeyError as e:
raise UserServiceError(
user_id, "user not found in store"
) from e
store = {"u-001": "Alice", "u-002": "Bob"}
try:
get_user(store, "u-404")
except UserServiceError as e:
print("Caught UserServiceError:", e)
print("Original cause:", repr(e.__cause__))
print("Chain preserved:", e.__cause__ is not None)
Exception chaining rules:
raise X from YsetsX.__cause__ = Yand Python prints both in the traceback.- This preserves the original error for debugging while presenting a clean domain error to callers.
- Use
raise X from Noneto suppress the chain entirely (useful when hiding internal implementation details for security). - Without
from, Python still setsX.__context__implicitly, butfrommakes the intent explicit.
Expected Output
Caught UserServiceError: Failed to load user 'u-404': user not found in store\nOriginal cause: KeyError('u-404')\nChain preserved: TrueHints
Hint 1: Use `raise YourError(...) from original_exception` to chain exceptions. This sets `__cause__` on the new exception.
Hint 2: The original exception is accessible via `e.__cause__` — callers can inspect it for debugging without coupling to the internal implementation.
Create two independent base exceptions: TimeoutError and DatabaseError. Then create DatabaseTimeoutError that inherits from both (a mixin). Give it table and timeout_seconds attributes. Verify with isinstance that it belongs to all three types.
class TimeoutError(Exception):
"""A generic timeout."""
pass
class DatabaseError(Exception):
"""A generic database error."""
pass
class DatabaseTimeoutError(TimeoutError, DatabaseError):
"""Database query timed out — catchable as either TimeoutError or DatabaseError."""
def __init__(self, table, timeout_seconds):
self.table = table
self.timeout_seconds = timeout_seconds
super().__init__(
"Query on '" + table + "' timed out after "
+ str(timeout_seconds) + "s"
)
# Test
err = DatabaseTimeoutError("orders", 30)
print("Is DatabaseTimeoutError:", isinstance(err, DatabaseTimeoutError))
print("Is TimeoutError:", isinstance(err, TimeoutError))
print("Is DatabaseError:", isinstance(err, DatabaseError))
print("Is Exception:", isinstance(err, Exception))
print("Table:", err.table)
print("Timeout:", err.timeout_seconds)Solution
class TimeoutError(Exception):
"""A generic timeout."""
pass
class DatabaseError(Exception):
"""A generic database error."""
pass
class DatabaseTimeoutError(TimeoutError, DatabaseError):
"""Database query timed out — catchable as either TimeoutError or DatabaseError."""
def __init__(self, table, timeout_seconds):
self.table = table
self.timeout_seconds = timeout_seconds
super().__init__(
"Query on '" + table + "' timed out after "
+ str(timeout_seconds) + "s"
)
err = DatabaseTimeoutError("orders", 30)
print("Is DatabaseTimeoutError:", isinstance(err, DatabaseTimeoutError))
print("Is TimeoutError:", isinstance(err, TimeoutError))
print("Is DatabaseError:", isinstance(err, DatabaseError))
print("Is Exception:", isinstance(err, Exception))
print("Table:", err.table)
print("Timeout:", err.timeout_seconds)
Why mixin exceptions work:
- An error can genuinely belong to two independent categories. A database timeout is both a timeout problem and a database problem.
- Callers handling all timeouts (
except TimeoutError) catch it. Callers handling all DB errors (except DatabaseError) also catch it. - Python's MRO (Method Resolution Order) resolves method calls correctly even with multiple inheritance.
- Keep mixin hierarchies shallow — two levels of multiple inheritance is the practical maximum.
Expected Output
Is DatabaseTimeoutError: True\nIs TimeoutError: True\nIs DatabaseError: True\nIs Exception: True\nTable: orders\nTimeout: 30Hints
Hint 1: Use multiple inheritance: `class DatabaseTimeoutError(TimeoutError, DatabaseError)` — this makes the exception catchable by either parent type.
Hint 2: Call `super().__init__()` with the message — Python MRO handles the rest. Store extra attributes before the super call.
Create a PaymentDeclinedError with attributes decline_code, amount, currency, processor_message, and retry_allowed. Add class-level constants for decline codes. Implement a to_dict() method that returns a JSON-serializable dictionary.
class PaymentDeclinedError(Exception):
"""A payment processor declined a charge."""
INSUFFICIENT_FUNDS = "insufficient_funds"
CARD_EXPIRED = "card_expired"
FRAUD_SUSPECTED = "fraud_suspected"
def __init__(
self, decline_code, amount, currency,
processor_message, retry_allowed=False
):
self.decline_code = decline_code
self.amount = amount
self.currency = currency
self.processor_message = processor_message
self.retry_allowed = retry_allowed
super().__init__(
"Payment declined [" + decline_code + "]: "
+ processor_message + " ("
+ str(amount) + " " + currency + ")"
)
def to_dict(self):
"""Return a JSON-serializable representation."""
return {
"error": "payment_declined",
"decline_code": self.decline_code,
"amount": self.amount,
"currency": self.currency,
"message": self.processor_message,
"retry_allowed": self.retry_allowed,
}
# Test
try:
raise PaymentDeclinedError(
decline_code=PaymentDeclinedError.CARD_EXPIRED,
amount=99.99,
currency="USD",
processor_message="Your card has expired",
retry_allowed=False,
)
except PaymentDeclinedError as e:
print("Message:", e)
print("Dict:", e.to_dict())
print("Retry allowed:", e.retry_allowed)Solution
class PaymentDeclinedError(Exception):
"""A payment processor declined a charge."""
INSUFFICIENT_FUNDS = "insufficient_funds"
CARD_EXPIRED = "card_expired"
FRAUD_SUSPECTED = "fraud_suspected"
def __init__(
self, decline_code, amount, currency,
processor_message, retry_allowed=False
):
self.decline_code = decline_code
self.amount = amount
self.currency = currency
self.processor_message = processor_message
self.retry_allowed = retry_allowed
super().__init__(
"Payment declined [" + decline_code + "]: "
+ processor_message + " ("
+ str(amount) + " " + currency + ")"
)
def to_dict(self):
"""Return a JSON-serializable representation."""
return {
"error": "payment_declined",
"decline_code": self.decline_code,
"amount": self.amount,
"currency": self.currency,
"message": self.processor_message,
"retry_allowed": self.retry_allowed,
}
try:
raise PaymentDeclinedError(
decline_code=PaymentDeclinedError.CARD_EXPIRED,
amount=99.99,
currency="USD",
processor_message="Your card has expired",
retry_allowed=False,
)
except PaymentDeclinedError as e:
print("Message:", e)
print("Dict:", e.to_dict())
print("Retry allowed:", e.retry_allowed)
Production error design principles:
- Structured attributes let code branch on
e.decline_codewithout parsing the message string. - Class constants (
CARD_EXPIRED,FRAUD_SUSPECTED) prevent typos and enable IDE autocomplete. to_dict()produces a clean JSON response for REST APIs — no ad-hoc serialization in handlers.retry_allowedis a boolean flag that lets the caller decide programmatically whether to retry — the exception carries the decision context.
Expected Output
Message: Payment declined [card_expired]: Your card has expired (99.99 USD)\nDict: {'error': 'payment_declined', 'decline_code': 'card_expired', 'amount': 99.99, 'currency': 'USD', 'message': 'Your card has expired', 'retry_allowed': False}\nRetry allowed: FalseHints
Hint 1: Add a `to_dict()` method that returns a plain dictionary with all the structured fields — this is what gets serialized to JSON in API responses.
Hint 2: Store constants like decline codes as class attributes (e.g., `CARD_EXPIRED = "card_expired"`) so callers can compare without hardcoding strings.
Hard
Design a complete exception module for a web application. Create:
AppError— base for all app errorsAuthError— withuser_id,reason, and optionaltoken_age_secondsRateLimitError— withuser_id,endpoint,limit,current,retry_after_seconds, and ato_dict()method
Demonstrate that each exception carries structured data and that the hierarchy supports both broad and specific catching.
class AppError(Exception):
"""Base exception for the application."""
pass
class AuthError(AppError):
"""Authentication or authorization failure."""
def __init__(self, user_id, reason, token_age_seconds=None):
self.user_id = user_id
self.reason = reason
self.token_age_seconds = token_age_seconds
msg = (
"Token " + reason + " for user '"
+ str(user_id) + "'"
)
if token_age_seconds is not None:
msg = msg + " (token issued " + str(token_age_seconds) + "s ago)"
super().__init__(msg)
class RateLimitError(AppError):
"""Too many requests from a user."""
def __init__(self, user_id, endpoint, limit, current, retry_after_seconds=60):
self.user_id = user_id
self.endpoint = endpoint
self.limit = limit
self.current = current
self.retry_after_seconds = retry_after_seconds
super().__init__(
"Rate limit exceeded for '" + str(user_id)
+ "' on '" + endpoint + "': "
+ str(current) + "/" + str(limit) + " requests"
)
def to_dict(self):
return {
"error": "rate_limit_exceeded",
"user_id": self.user_id,
"endpoint": self.endpoint,
"limit": self.limit,
"current": self.current,
"retry_after_seconds": self.retry_after_seconds,
}
# Test AuthError
try:
raise AuthError("u-100", "expired", token_age_seconds=3600)
except AuthError as e:
print("AuthError:", e)
print("User:", e.user_id)
print("Reason:", e.reason)
print("Token age:", e.token_age_seconds)
print("---")
# Test hierarchy
err = AuthError("u-100", "expired")
print("Caught as AuthError:", isinstance(err, AuthError))
print("Caught as AppError:", isinstance(err, AppError))
print("---")
# Test RateLimitError
try:
raise RateLimitError("u-200", "/api/data", 100, 101, retry_after_seconds=30)
except RateLimitError as e:
print("RateLimitError:", e)
print("Retry after:", e.retry_after_seconds)
print("Dict keys:", sorted(e.to_dict().keys()))Solution
class AppError(Exception):
"""Base exception for the application."""
pass
class AuthError(AppError):
"""Authentication or authorization failure."""
def __init__(self, user_id, reason, token_age_seconds=None):
self.user_id = user_id
self.reason = reason
self.token_age_seconds = token_age_seconds
msg = (
"Token " + reason + " for user '"
+ str(user_id) + "'"
)
if token_age_seconds is not None:
msg = msg + " (token issued " + str(token_age_seconds) + "s ago)"
super().__init__(msg)
class RateLimitError(AppError):
"""Too many requests from a user."""
def __init__(self, user_id, endpoint, limit, current, retry_after_seconds=60):
self.user_id = user_id
self.endpoint = endpoint
self.limit = limit
self.current = current
self.retry_after_seconds = retry_after_seconds
super().__init__(
"Rate limit exceeded for '" + str(user_id)
+ "' on '" + endpoint + "': "
+ str(current) + "/" + str(limit) + " requests"
)
def to_dict(self):
return {
"error": "rate_limit_exceeded",
"user_id": self.user_id,
"endpoint": self.endpoint,
"limit": self.limit,
"current": self.current,
"retry_after_seconds": self.retry_after_seconds,
}
# AuthError test
try:
raise AuthError("u-100", "expired", token_age_seconds=3600)
except AuthError as e:
print("AuthError:", e)
print("User:", e.user_id)
print("Reason:", e.reason)
print("Token age:", e.token_age_seconds)
print("---")
err = AuthError("u-100", "expired")
print("Caught as AuthError:", isinstance(err, AuthError))
print("Caught as AppError:", isinstance(err, AppError))
print("---")
# RateLimitError test
try:
raise RateLimitError("u-200", "/api/data", 100, 101, retry_after_seconds=30)
except RateLimitError as e:
print("RateLimitError:", e)
print("Retry after:", e.retry_after_seconds)
print("Dict keys:", sorted(e.to_dict().keys()))
Library exception module design:
AppError <-- catch-all for your app
├── AuthError <-- auth/authz failures
│ ├── user_id, reason
│ └── token_age_seconds (optional)
├── RateLimitError <-- too many requests
│ ├── user_id, endpoint, limit, current
│ ├── retry_after_seconds
│ └── to_dict() for JSON API responses
├── ValidationError <-- input validation (extend as needed)
└── StorageError <-- database/storage failures
- Every exception at every level carries attributes, not just strings.
to_dict()on API-facing exceptions produces clean JSON.- Optional attributes (like
token_age_seconds) enrich the error without making it harder to raise in simple cases.
Expected Output
AuthError: Token expired for user 'u-100' (token issued 3600s ago)\nUser: u-100\nReason: expired\nToken age: 3600\n---\nCaught as AuthError: True\nCaught as AppError: True\n---\nRateLimitError: Rate limit exceeded for 'u-200' on '/api/data': 101/100 requests\nRetry after: 30\nDict keys: ['error', 'user_id', 'endpoint', 'limit', 'current', 'retry_after_seconds']Hints
Hint 1: Start with a single `AppError` base. Create category exceptions like `AuthError` and `RateLimitError` that each carry domain-specific attributes. Every leaf exception should have structured data accessible as attributes.
Hint 2: The `RateLimitError` should include a `to_dict()` method and a `retry_after_seconds` attribute so that API handlers can set the `Retry-After` HTTP header programmatically.
Create a ValidationError that collects multiple field errors into a single exception. It should hold a list of FieldError objects (each with field and message). Implement __str__ to show a summary count and all individual errors. Add a fields property that returns just the field names.
class FieldError:
"""A single field validation failure."""
def __init__(self, field, message):
self.field = field
self.message = message
def __str__(self):
return "Field '" + self.field + "': " + self.message
class ValidationError(Exception):
"""Collects multiple field validation errors into one exception."""
def __init__(self, errors):
self.errors = errors
count = len(errors)
summary = str(count) + " validation error"
if count != 1:
summary = summary + "s"
lines = [summary]
for err in errors:
lines.append(" - " + str(err))
super().__init__("\n".join(lines))
@property
def fields(self):
"""Return list of field names that failed validation."""
return [e.field for e in self.errors]
def validate_user(data):
"""Validate user data, collecting all errors before raising."""
errors = []
name = data.get("name")
if not isinstance(name, str) or not name.strip():
errors.append(FieldError("name", "must be a non-empty string"))
age = data.get("age")
if not isinstance(age, int):
errors.append(
FieldError("age", "must be int, got " + type(age).__name__)
)
elif age < 0 or age >= 150:
errors.append(FieldError("age", "must be between 0 and 149"))
email = data.get("email", "")
if "@" not in str(email):
errors.append(FieldError("email", "must contain @"))
if errors:
raise ValidationError(errors)
return data
# Test
try:
validate_user({"name": "", "age": "twenty", "email": "bad"})
except ValidationError as e:
print("ValidationError:", e)
print("Error count:", len(e.errors))
print("First error field:", e.errors[0].field)
print("All fields:", e.fields)Solution
class FieldError:
"""A single field validation failure."""
def __init__(self, field, message):
self.field = field
self.message = message
def __str__(self):
return "Field '" + self.field + "': " + self.message
class ValidationError(Exception):
"""Collects multiple field validation errors into one exception."""
def __init__(self, errors):
self.errors = errors
count = len(errors)
summary = str(count) + " validation error"
if count != 1:
summary = summary + "s"
lines = [summary]
for err in errors:
lines.append(" - " + str(err))
super().__init__("\n".join(lines))
@property
def fields(self):
"""Return list of field names that failed validation."""
return [e.field for e in self.errors]
def validate_user(data):
"""Validate user data, collecting all errors before raising."""
errors = []
name = data.get("name")
if not isinstance(name, str) or not name.strip():
errors.append(FieldError("name", "must be a non-empty string"))
age = data.get("age")
if not isinstance(age, int):
errors.append(
FieldError("age", "must be int, got " + type(age).__name__)
)
elif age < 0 or age >= 150:
errors.append(FieldError("age", "must be between 0 and 149"))
email = data.get("email", "")
if "@" not in str(email):
errors.append(FieldError("email", "must contain @"))
if errors:
raise ValidationError(errors)
return data
try:
validate_user({"name": "", "age": "twenty", "email": "bad"})
except ValidationError as e:
print("ValidationError:", e)
print("Error count:", len(e.errors))
print("First error field:", e.errors[0].field)
print("All fields:", e.fields)
The collector pattern:
- Instead of raising on the first error, collect ALL errors and raise once. Users see every problem in one shot rather than fixing them one at a time.
- This is exactly what Pydantic does —
ValidationErrorholds a list of individual field errors. - The
fieldsproperty gives callers quick access to which fields failed, useful for highlighting form fields in a UI. FieldErroris a simple data holder — in production you might usedataclasses.dataclassorNamedTuple.
Expected Output
ValidationError: 3 validation errors\n - Field 'name': must be a non-empty string\n - Field 'age': must be int, got str\n - Field 'email': must contain @\nError count: 3\nFirst error field: name\nAll fields: ['name', 'age', 'email']Hints
Hint 1: Create a `FieldError` dataclass or simple class to hold `field` and `message`. Then create `ValidationError` that holds a list of `FieldError` instances.
Hint 2: Build a custom `__str__` that formats all errors into a multi-line string. The `errors` attribute is a list that callers iterate over programmatically.
Create a ServiceUnavailableError with attributes method, endpoint, reason, retryable, and retry_after (seconds). Then write a retry_on_unavailable function that retries the operation up to max_retries times, but only if the exception says it is retryable. Use a counter to simulate a service that becomes available after N failures.
import time
class ServiceUnavailableError(Exception):
"""A service is temporarily unavailable."""
def __init__(self, method, endpoint, reason, retryable=True, retry_after=1):
self.method = method
self.endpoint = endpoint
self.reason = reason
self.retryable = retryable
self.retry_after = retry_after
super().__init__(
"ServiceUnavailableError on '"
+ method + " " + endpoint + "': " + reason
+ " (retryable=" + str(retryable)
+ ", retry_after=" + str(retry_after) + ")"
)
def retry_on_unavailable(func, max_retries=3):
"""Retry a function if it raises a retryable ServiceUnavailableError."""
last_error = None
for attempt in range(1, max_retries + 1):
try:
return func()
except ServiceUnavailableError as e:
last_error = e
if not e.retryable:
raise
print(
"Attempt " + str(attempt) + " failed: " + str(e)
)
# In production: time.sleep(e.retry_after)
raise last_error
# Simulate a service that recovers after 2 failures
call_count = 0
def health_check():
global call_count
call_count += 1
if call_count < 3:
raise ServiceUnavailableError(
"GET", "/api/health", "service starting up",
retryable=True, retry_after=1
)
return "healthy"
result = retry_on_unavailable(health_check)
print("Attempt 3 succeeded:", result)
print("---")
# Non-retryable error
def locked_deploy():
raise ServiceUnavailableError(
"GET", "/api/deploy", "deployment locked",
retryable=False, retry_after=0
)
try:
retry_on_unavailable(locked_deploy)
except ServiceUnavailableError as e:
print("Non-retryable error caught:", e)Solution
import time
class ServiceUnavailableError(Exception):
"""A service is temporarily unavailable."""
def __init__(self, method, endpoint, reason, retryable=True, retry_after=1):
self.method = method
self.endpoint = endpoint
self.reason = reason
self.retryable = retryable
self.retry_after = retry_after
super().__init__(
"ServiceUnavailableError on '"
+ method + " " + endpoint + "': " + reason
+ " (retryable=" + str(retryable)
+ ", retry_after=" + str(retry_after) + ")"
)
def retry_on_unavailable(func, max_retries=3):
"""Retry a function if it raises a retryable ServiceUnavailableError."""
last_error = None
for attempt in range(1, max_retries + 1):
try:
return func()
except ServiceUnavailableError as e:
last_error = e
if not e.retryable:
raise
print(
"Attempt " + str(attempt) + " failed: " + str(e)
)
# In production: time.sleep(e.retry_after)
raise last_error
call_count = 0
def health_check():
global call_count
call_count += 1
if call_count < 3:
raise ServiceUnavailableError(
"GET", "/api/health", "service starting up",
retryable=True, retry_after=1
)
return "healthy"
result = retry_on_unavailable(health_check)
print("Attempt 3 succeeded:", result)
print("---")
def locked_deploy():
raise ServiceUnavailableError(
"GET", "/api/deploy", "deployment locked",
retryable=False, retry_after=0
)
try:
retry_on_unavailable(locked_deploy)
except ServiceUnavailableError as e:
print("Non-retryable error caught:", e)
Retry-aware exception design:
retryableis a boolean that tells the caller whether retrying makes sense — not all errors are transient.retry_aftertells the caller HOW LONG to wait — this mirrors the HTTPRetry-Afterheader.- The retry function reads these attributes to make programmatic decisions. No string parsing. No guesswork.
- Non-retryable errors are re-raised immediately — the exception itself carries the "should I retry?" answer.
- In production, you would call
time.sleep(e.retry_after)between retries and add exponential backoff.
Expected Output
Attempt 1 failed: ServiceUnavailableError on 'GET /api/health': service starting up (retryable=True, retry_after=1)\nAttempt 2 failed: ServiceUnavailableError on 'GET /api/health': service starting up (retryable=True, retry_after=1)\nAttempt 3 succeeded: healthy\n---\nNon-retryable error caught: ServiceUnavailableError on 'GET /api/deploy': deployment locked (retryable=False, retry_after=0)Hints
Hint 1: Create `ServiceUnavailableError` with `retryable` and `retry_after` attributes. Then write a `retry_on_unavailable` function that checks `e.retryable` before retrying.
Hint 2: The retry logic reads `e.retry_after` to know how long to wait. If `e.retryable` is False, it re-raises immediately. This turns exception attributes into control-flow decisions.
