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:
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:
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:
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
ToolErrorspecifically to handle tool failures one way - catch
ConfigErrorspecifically for config problems - catch
AgentErrorbroadly 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.
What does a custom exception class actually need, at minimum, to work as a raisable, catchable exception?