Custom exceptions and exception hierarchies

What a generic exception loses

Every exception you’ve raised so far has been a built-in type — ValueError, TypeError. That works, but it loses information the moment more than one thing in your own code can go wrong for different reasons:

def load_tool_config(raw: dict):
    if "name" not in raw:
        raise ValueError("missing name")
    if "timeout" in raw and raw["timeout"] <= 0:
        raise ValueError("timeout must be positive")
    return raw

try:
    load_tool_config({"timeout": -1})
except ValueError:
    # ...but which problem was it? a missing field, or a bad value?
    # catching ValueError alone can't tell the two apart without
    # inspecting the message string itself
    print("something was wrong with the config")

(not run live — illustrating the problem, not a working demo)

Both failures raise the exact same exception type — the only way to tell them apart from the except side is parsing the error message’s text, which is fragile and not really what exception types are for.

Defining your own exception

An exception is just a class — specifically, one that inherits from Exception (or one of its subclasses), the exact inheritance mechanism from the OOP lesson. Defining a new one can be as short as one line:

Try it — edit and run

class ToolError(Exception): pass inherits everything Exception already does — including accepting a message and making it available via str(e) — so ToolError("...") behaves exactly like ValueError("...") did, just as its own distinct, specifically-named type. Now except ToolError: catches only tool-related failures, not any other ValueError that might be raised elsewhere in the same try block for a completely unrelated reason.

Adding your own data to an exception

Since a custom exception is a normal class, it can have its own __init__ and carry structured data beyond just a message string — the same super().__init__() pattern from the OOP lesson applies here too:

Try it — edit and run

super().__init__(f"...") still sets up the message str(e) shows, but self.tool_name and self.reason are also directly available on the caught exception — code handling the error can react to e.tool_name programmatically, not just display a string.

Building a small hierarchy

Real code usually has more than one kind of error worth distinguishing — and those errors are often naturally related, which is exactly what inheritance is for. A shared base class lets you catch broadly or narrowly, depending on what the calling code actually needs to do:

Try it — edit and run

except AgentError: catches the ToolError here, because a ToolError is an AgentError — the same isinstance relationship covered for your own classes back in the OOP lesson’s inheritance concept, which applies identically to exception classes, since exceptions are just classes like any other. This means calling code has a real choice, all without changing how the exceptions are raised:

  • catch ToolError specifically to handle tool failures one way
  • catch ConfigError specifically for config problems
  • catch AgentError broadly to handle “anything that went wrong in my own agent code” in one place

This is also exactly the ordering trap from the Python setup lesson’s ArithmeticError/ZeroDivisionError example, now with your own classes: an except AgentError: listed before except ToolError: on the same try would catch every ToolError first, making the more specific clause below it unreachable — the fix is identical too, list the more specific exception type first.

Check your understanding
1/6

What does a custom exception class actually need, at minimum, to work as a raisable, catchable exception?