Skip to main content

Python Custom Exceptions Practice Problems & Exercises

Practice: Custom Exceptions

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Minimal Custom ExceptionEasy
custom-exceptioninheritanceraise

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.

Python
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): pass is a complete, valid custom exception.
  • It inherits all of Exception's machinery: args, __str__, __repr__.
  • Callers can now write except ItemNotFoundError to catch only this specific error, not every KeyError or ValueError in 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: True
Hints

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")`.

#2Exception with Custom AttributesEasy
attributes__init__super()

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.

Python
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_id and e.shortfall as structured data — no string parsing needed.
  • super().__init__(message) ensures str(e) returns the human-readable message for logging.
  • The shortfall is 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.00
Hints

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.

#3Subclassing a Built-in ExceptionEasy
subclassValueErrorspecialization

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.

Python
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 ValueError is the correct parent.
  • Callers who write except ValueError will catch NegativeAmountError automatically — this is correct behavior.
  • Callers who need precision can write except NegativeAmountError for targeted handling.
  • This mirrors Python's own design: FileNotFoundError inherits from OSError.
Expected Output
Caught as NegativeAmountError: 'price' must be positive, got -29.99\nAlso a ValueError: True\nField: price\nAmount: -29.99
Hints

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__()`.

#4Exception with __str__ and __repr__Easy
__str____repr__formatting

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.

Python
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 for str() 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

#5Exception Hierarchy for a LibraryMedium
hierarchybase-exceptionlibrary-design

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.

Python
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 PipelineError to 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: True
Hints

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.

#6Exception Chaining with raise...fromMedium
chainingraise-fromwrapping

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.

Python
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 Y sets X.__cause__ = Y and 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 None to suppress the chain entirely (useful when hiding internal implementation details for security).
  • Without from, Python still sets X.__context__ implicitly, but from makes 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: True
Hints

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.

#7Mixin Exception PatternMedium
mixinmultiple-inheritanceisinstance

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.

Python
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: 30
Hints

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.

#8Exception with to_dict for APIsMedium
to_dictserializationapi-design

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.

Python
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_code without 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_allowed is 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: False
Hints

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

#9Full Library Exception ModuleHard
hierarchyproductionlibrary-designchaining

Design a complete exception module for a web application. Create:

  • AppError — base for all app errors
  • AuthError — with user_id, reason, and optional token_age_seconds
  • RateLimitError — with user_id, endpoint, limit, current, retry_after_seconds, and a to_dict() method

Demonstrate that each exception carries structured data and that the hierarchy supports both broad and specific catching.

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

#10Validation Error CollectorHard
multiple-errorscollector-patternproduction

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.

Python
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 — ValidationError holds a list of individual field errors.
  • The fields property gives callers quick access to which fields failed, useful for highlighting form fields in a UI.
  • FieldError is a simple data holder — in production you might use dataclasses.dataclass or NamedTuple.
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.

#11Retry-Aware Exception with Context ManagerHard
retrycontext-managerproduction-pattern

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.

Python
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:

  • retryable is a boolean that tells the caller whether retrying makes sense — not all errors are transient.
  • retry_after tells the caller HOW LONG to wait — this mirrors the HTTP Retry-After header.
  • 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.

© 2026 EngineersOfAI. All rights reserved.