An Exception Hierarchy of Your Own

The Course
•
September 19, 2026
•
9 min read

Warm-up: from Day 3

From memory first.

1. Predict the line. Door.close isn’t even a method.

Warm-up: predict Python
from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...

class File:
    def close(self):
        print("closed")

class Door:
    close = "not a method at all"

print(isinstance(File(), Closable), isinstance(Door(), Closable), isinstance(3, Closable))
Check your prediction
True True False

A runtime isinstance check against a protocol only asks whether the names exist — not their types, not whether they’re callable. That’s why the Day 3 checkpoint said its tests could only check names, and why the rest is mypy’s job. From Protocols: Say What You Use.

2. Fix. A “fast path” loader should still refuse bad data: this should print refused: views must be a non-negative int.

Warm-up: fix Python
class Positive:
    def __set_name__(self, owner, name):
        self.name, self.slot = name, "_" + name

    def __get__(self, instance, owner=None):
        return instance.__dict__[self.slot]

    def __set__(self, instance, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"{self.name} must be a non-negative int")
        instance.__dict__[self.slot] = value

class Post:
    views = Positive()

def load(row):
    post = Post.__new__(Post)
    post.__dict__["_views"] = row["views"]      # the "fast path"
    return post

try:
    load({"views": "lots"})
    print("loaded")
except ValueError as error:
    print("refused:", error)
Check your fix
class Positive:
    def __set_name__(self, owner, name):
        self.name, self.slot = name, "_" + name

    def __get__(self, instance, owner=None):
        return instance.__dict__[self.slot]

    def __set__(self, instance, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"{self.name} must be a non-negative int")
        instance.__dict__[self.slot] = value

class Post:
    views = Positive()

def load(row):
    post = Post.__new__(Post)
    post.views = row["views"]                   # through the descriptor
    return post

try:
    load({"views": "lots"})
    print("loaded")
except ValueError as error:
    print("refused:", error)
refused: views must be a non-negative int

Writing into __dict__ goes behind the descriptor, so its check never runs. Day 3’s bug hunt, the fast path that let a string in.

3. Explain. No code: what can a type checker catch that tests can’t — and what can tests catch that a checker can’t?

Check your explanation

A checker catches mismatched shapes — wrong types, misuse in callers’ code, a protocol a real class doesn’t satisfy — before anything runs, including in code no test exercises. Tests catch behaviour: that save commits, that bad values are refused. Neither replaces the other. From Day 3 Checkpoint: What the Checker Can’t See.

Today

So far the library raises ValueError for a bad field, LookupError for a missing row, and lets sqlite3.IntegrityError straight through. Each choice is defensible on its own, and together they leave a caller with no way to say “catch anything this library raises” — and no way to tell your ValueError from one thrown by their own code three frames down.

One base class, and no more than you need

class ORMError(Exception):
    """Base class for every error raised by this library."""


class ValidationError(ORMError, ValueError): ...
class NotFound(ORMError, LookupError): ...
class IntegrityError(ORMError): ...

ORMError is the whole point: except ORMError is a complete answer to “this library failed”. Below it, one class per situation a caller might handle differently. Not per message, and not per call site — if two errors would always be caught together, they’re one class with two messages.

Don't insert classes in the middle later

A hierarchy is a promise about what catches what. Adding NotFound under ValidationError later, because both are “bad input”, silently changes which except clauses fire in code you’ve never seen. Get the shape right early and then only add leaves.

Keep the built-in meaning

Each error also inherits from the built-in it used to be:

class ValidationError(ORMError, ValueError): ...
class NotFound(ORMError, LookupError): ...

This is what makes the change backwards-compatible. Code written against yesterday’s library says except ValueError and keeps working, while new code says except ValidationError.

Both spellings work Python
class ORMError(Exception):
    """Base class for every error raised by this library."""


class ValidationError(ORMError, ValueError):
    """A value a field refused."""


def check(value):
    if not isinstance(value, int):
        raise ValidationError(f"views must be an int, not {type(value).__name__}")
    return value


for catcher in (ORMError, ValueError, Exception):
    try:
        check("lots")
    except catcher as error:
        print(f"except {catcher.__name__:12} caught {type(error).__name__}: {error}")

print("MRO:", " -> ".join(cls.__name__ for cls in ValidationError.__mro__))

It is a genuine trade, not a free win. except ValueError around a call into your library will now also swallow your validation errors, which a caller may not have intended. The alternative — a clean break — is worse for exactly one release and better forever after, so the usual answer is: inherit from the built-in now, document that you’ll drop it in the next major version, and mean it.

LookupError for NotFound is worth a second look, because KeyError is the obvious alternative. KeyError prints its argument with repr and nothing else — KeyError: 99 — and it means “this key isn’t in this mapping”. A row that doesn’t exist isn’t a mapping lookup, and the message matters more than the family. LookupError is the parent of both KeyError and IndexError, and says what you mean without inheriting that formatting.

Translating driver errors

sqlite3.IntegrityError escaping from save() is a leak of the same kind as sqlite3.Connection in a signature: it ties every caller to today’s driver. So the library wraps it:

        if isinstance(error, errors.DRIVER_INTEGRITY_ERRORS):
            raise errors.IntegrityError(str(error)) from error

Two details carry the weight.

from error sets __cause__, so the traceback shows both:

sqlite3.IntegrityError: UNIQUE constraint failed: post.title

The above exception was the direct cause of the following exception:

errors.IntegrityError: UNIQUE constraint failed: post.title

Nothing is hidden. A caller handles errors.IntegrityError; a maintainer debugging a weird case still sees exactly what the driver said. Compare yesterday’s raise ... from None in Field.__get__: there the inner KeyError was an implementation detail with no diagnostic value. Here the inner exception is the diagnosis.

from error, from None, and neither Python
class ORMError(Exception):
    pass


def with_cause():
    try:
        raise KeyError("_views")
    except KeyError as error:
        raise ORMError("field is not set") from error


def hidden():
    try:
        raise KeyError("_views")
    except KeyError as error:
        raise ORMError("field is not set") from None


def implicit():
    try:
        raise KeyError("_views")
    except KeyError:
        raise ORMError("field is not set")


for function in (with_cause, hidden, implicit):
    try:
        function()
    except ORMError as error:
        print(f"{function.__name__:12} __cause__={error.__cause__!r} "
              f"__context__={error.__context__!r}")

implicit — raising inside an except without saying anything — still records the original as __context__, and the traceback reads “During handling of the above exception, another exception occurred”. That’s the default, and it’s usually fine internally; from error upgrades it to a deliberate statement, and from None is the only way to actually hide something.

Which driver exceptions? The library can’t import sqlite3 — Day 3 was about not tying itself to one driver — so it can’t name them. Instead, whoever owns the connection registers them:

DRIVER_INTEGRITY_ERRORS: tuple[type[BaseException], ...] = ()


def use_driver(*, integrity: type[BaseException]) -> None:
    global DRIVER_INTEGRITY_ERRORS
    DRIVER_INTEGRITY_ERRORS = (integrity,)

db.connect() calls it. This is module-level mutable state, which is a real cost: two drivers in one process would fight over it, and a test that registers one leaks into the next. A bigger library solves this with a dialect object passed around explicitly. Day 7 revisits it as one of the design trade-offs worth writing down rather than hiding.

Exercise

Design the hierarchy

Build the errors for a small HTTP client, so the tests describing how callers want to handle them pass. Read the tests first — they’re the specification, and they imply which errors share a parent.

import unittest


class ClientError(Exception):
    """TODO: base for everything this client raises."""


# TODO: Retryable, Timeout, ConnectionFailed, HTTPError(status),
# NotFound and ServerError. The tests decide how they relate.


class Errors(unittest.TestCase):
    def test_everything_is_a_client_error(self):
        for error in (Timeout, ConnectionFailed, HTTPError, NotFound, ServerError):
            self.assertTrue(issubclass(error, ClientError), error.__name__)

    def test_retryable_errors_share_a_parent(self):
        """A caller should be able to catch "worth retrying" in one clause."""
        for error in (Timeout, ConnectionFailed, ServerError):
            self.assertTrue(issubclass(error, Retryable), error.__name__)
        self.assertFalse(issubclass(NotFound, Retryable))

    def test_a_timeout_is_also_the_builtin_one(self):
        self.assertTrue(issubclass(Timeout, TimeoutError))

    def test_http_errors_carry_their_status(self):
        error = HTTPError(503, "Service Unavailable")
        self.assertEqual(error.status, 503)
        self.assertIn("503", str(error))
        self.assertIn("Service Unavailable", str(error))

    def test_not_found_knows_its_own_status(self):
        self.assertEqual(NotFound("/posts/1").status, 404)
        self.assertIn("/posts/1", str(NotFound("/posts/1")))

    def test_a_server_error_is_any_5xx(self):
        self.assertEqual(ServerError(503).status, 503)
        self.assertTrue(issubclass(ServerError, HTTPError))


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Errors)
)
Hint 1: the idea

The tests are the spec, and they group errors by how a caller will handle them, not by where they come from. Look for the grouping that cuts across the obvious one: which errors should a single except clause catch as “worth trying again”? A class can have two parents — that’s how one error sits in two groups.

Hint 2: a first step

Build the plain tree first: ClientError, then Retryable under it, then Timeout and ConnectionFailed under Retryable. Run; the first tests pass. Then HTTPError with a status — a class-level default lets NotFound know its own status without being told.

Show one solution
class ClientError(Exception):
    """Base for everything this client raises."""


class Retryable(ClientError):
    """Worth trying again: nothing about the request itself was wrong."""


class Timeout(Retryable, TimeoutError):
    """The server took too long."""


class ConnectionFailed(Retryable):
    """The connection could not be made."""


class HTTPError(ClientError):
    """The server answered with an error status."""

    status = 0

    def __init__(self, status=None, message=""):
        self.status = type(self).status if status is None else status
        super().__init__(f"{self.status} {message}".strip())


class NotFound(HTTPError):
    status = 404

    def __init__(self, path=""):
        super().__init__(None, path)


class ServerError(HTTPError, Retryable):
    """Any 5xx."""

The interesting design decision is Retryable, and the test drove it: callers want one except clause for “try again”, and that cuts across the obvious grouping. A timeout and a 503 have nothing in common technically — one never reached the server, the other came back from it — but they share the only thing the caller cares about.

That’s the rule worth taking away: group by how it will be handled, not by where it came from. ServerError(HTTPError, Retryable) sits in both hierarchies, which is exactly what multiple inheritance is for here.

status as a class attribute with an instance override lets NotFound know its own status while HTTPError(503, ...) still takes one. And Timeout inheriting TimeoutError is the same backwards-compatibility move the ORM makes: code already catching the built-in keeps working.

What you learned

  • One base class per library, and one subclass per situation a caller handles differently.
  • Inheriting the built-in your error replaces keeps existing except clauses working through the change.
  • raise ... from error when the cause helps diagnosis, from None when it’s an implementation detail; doing neither still records __context__.
  • Driver exceptions must be translated at the boundary, and the library needs some way to learn about them that isn’t an import.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.