Skip to content
← All articles

Your exception hierarchy is an API

The exceptions your library raises are a public contract you never wrote down. How to design one on purpose: a package-level base, subclassing along recoverability, structured attributes instead of encoded messages, and dual-inheriting from a builtin so existing handlers survive.

Every library you ship has two public APIs. There is the one in the docs — the functions people call, the arguments they pass. And there is the one nobody writes down: the set of exceptions people have to except. The second one is usually designed by accident, one raise ValueError at a time, and then it is frozen forever because someone’s production code depends on it.

This article is about designing the second one on purpose.

The failure mode

Here is a client library that grew organically:

def charge(card: str, cents: int) -> Receipt:
    if cents <= 0:
        raise ValueError("cents must be positive")
    resp = requests.post(URL, json={"card": card, "cents": cents})
    resp.raise_for_status()          # raises requests.HTTPError
    return Receipt(**resp.json())    # raises KeyError on an unexpected payload

Three different failures, three unrelated exception types, one of them owned by a third-party package. Now put yourself in the caller’s seat. You want to retry a gateway hiccup but not a declined card, and you want to log a bad payload as a bug rather than as a user error. What do you write?

You have two options and both are bad. Option one:

try:
    receipt = charge(card, cents)
except Exception:
    ...

This catches your bugs too — the AttributeError from the typo you shipped last Tuesday now looks like a payment failure and gets retried three times.

Option two:

except (ValueError, requests.HTTPError, KeyError):

Now the caller’s code names requests — a package it does not import and did not choose. The day you migrate to httpx, you break every user, and the breakage does not show up in your type signatures, your tests, or your changelog. You made an implementation detail load-bearing.

💡A colleague argues the second option is fine as long as the docstring lists the exceptions. What is the argument against? click to reveal

A docstring is not a contract the tooling can check, and it is not a contract the caller can subscribe to. Three specific problems:

Nothing enforces it. Add a code path that raises TimeoutError and no test, type checker or linter notices that the docstring is now wrong. The list rots on the first refactor.

It cannot express the interesting question. Callers do not want a list of types; they want to know which of these should I retry. A flat enumeration makes every caller re-derive that mapping, independently, and get it subtly different.

It pins your internals. The moment the docstring says requests.HTTPError, swapping HTTP libraries is a breaking change even though nothing in your signatures moved. You have coupled your callers to a dependency they never asked for.

A base class fixes all three at once: it is checkable (issubclass), it expresses the axis, and it lets you swap internals freely as long as you keep translating at the boundary.

Rule 1: one package-level base

Every library gets exactly one root:

class PaymentError(Exception):
    # Base for every error this package raises. Catch this to mean
    # "the payments library failed" without catching your own bugs.
    pass

except PaymentError is now a complete, stable, honest contract. It says “something in the payment library went wrong” and it will keep saying that through every internal rewrite you ever do. That single class is worth more to your users than the rest of your error design combined.

Nothing else in your public surface may escape. Anything a dependency raises, you catch at your own boundary and translate — chained, so the original is still there (that is the next item in this track).

Rule 2: subclass along the axis callers branch on

Here is where most hierarchies go wrong. The tempting split is by where the error happened: HttpError, ParseError, ValidationError, DatabaseError. That is a map of your source tree, not of your caller’s decisions.

What does the caller actually do differently? Almost always exactly one thing: do I retry, or do I stop? So split on recoverability first:

class TransientPaymentError(PaymentError):
    def __init__(self, message: str, *, retry_after: float) -> None:
        super().__init__(message)
        self.retry_after = retry_after

class PermanentPaymentError(PaymentError):
    pass

and only then refine underneath, where a caller has a genuine reason to care:

class CardDeclinedError(PermanentPaymentError):
    def __init__(self, message: str, *, decline_code: str) -> None:
        super().__init__(message)
        self.decline_code = decline_code

Now the retry loop is except TransientPaymentError — a class that will keep meaning the same thing in five years — and the checkout page that wants to show “your bank said no” catches CardDeclinedError without knowing or caring how you talk to the gateway.

The test for a good hierarchy: can you add a new internal failure mode without adding a new public class? With the recoverability split, yes — a new kind of gateway wobble is just another TransientPaymentError. With the source-tree split you invent GrpcError and every caller has to be told.

Rule 3: structured attributes, never encoded messages

# Wrong. The message is now an API.
raise PaymentError(f"rate limited, retry after {seconds}s")

# Right.
raise TransientPaymentError("rate limited", retry_after=seconds)

The first version forces callers to write a regex against your prose. The moment you improve the wording — or translate it, or add a request id — you break them, silently, at runtime, in production. The second version can have its message rewritten freely because the data lives somewhere the caller can reach.

Make the attributes keyword-only (*, retry_after: float) so nobody can pass them positionally and get bitten when you add a field. And annotate them, so mypy --strict propagates the type to every reader.

💡Your error attribute is optional — some transient failures know a retry delay and some do not. Do you use retry_after: float | None = None, or two exception classes? click to reveal

Use the optional attribute, and default it to a real number rather than to None if you can defend one.

Two classes (RateLimitedError with the delay, TransientPaymentError without) means every caller writes two handlers for one decision. Class-per-shape multiplies the surface a caller must learn, and the axis it splits on — “did the server happen to send a Retry-After header” — is not an axis anyone branches on. It is a data question, and data questions belong in attributes.

float | None is honest and typed: mypy will force the caller to handle the None before doing arithmetic with it, which is exactly the bug you want caught. But if your library can always supply a sane default (say, one second), consider retry_after: float = 1.0 instead. Every None in a public attribute is a decision you have pushed onto every caller; only push it when the caller genuinely knows better than you do.

Rule 4: inherit from a builtin too, when it is honestly true

This is the trick that lets you retrofit a hierarchy onto a shipped library without a breaking change:

class ConfigurationError(PaymentError, ValueError):
    pass

Both except PaymentError and except ValueError catch it. Existing users who wrote except ValueError around your setup call keep working; new users get the better class. You can run that migration for a whole major version and then decide whether to drop the second base.

The MRO is ConfigurationError -> PaymentError -> ValueError -> Exception, so your own base wins for anything you define on it.

The word doing the work is honestly. ConfigurationError really is a bad value passed by the caller, so ValueError is true. Do not bolt KeyError onto something that is not a failed lookup just to keep an old handler alive — you will be lying to isinstance, and someone will build on the lie.

The ruff TRY003 question, honestly

Ruff’s TRY003 flags long messages passed at the raise site, on the theory that message text belongs inside the exception class. In practice most teams disable it, and they are usually right. The alternative it pushes you toward is a class per message, and a hierarchy with forty leaf classes is harder to catch against, not easier — you have moved the prose into class names, where it is less readable and much harder to change.

The defensible middle: message text at the raise site, structured data as attributes, and a class only when someone will except it. If no caller would ever branch on it, it does not need to be a class.

💡How many exception classes should a mid-sized library expose? click to reveal

Fewer than you think — one per decision a caller makes, not one per failure mode you can name.

A useful test: for each class you are about to add, write the except clause that would catch it and the code inside that clause. If that code is identical to the handler for its parent, delete the class and use an attribute instead. If you cannot imagine anyone writing the handler at all, it is internal and should not be public.

Most libraries land at three to six: the base, a transient/permanent split, one or two refinements callers really do branch on, and a configuration error that also subclasses ValueError. requests exposes roughly that; so does httpx. A hierarchy that needs a diagram is a hierarchy nobody will use correctly.

The shape to copy

class PaymentError(Exception): ...

class TransientPaymentError(PaymentError):
    # retry_after: float
    ...

class PermanentPaymentError(PaymentError): ...

class CardDeclinedError(PermanentPaymentError):
    # decline_code: str
    ...

class ConfigurationError(PaymentError, ValueError): ...

Five classes. One axis. Data on the exceptions, not in the prose. One of them dual-inherits so the old handlers keep working. That is a design you can defend in review, and — more to the point — one your users can build a retry policy on without reading your source.