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.