skip to content

How would you evolve a published library's exception hierarchy without breaking existing except clauses?

level: principalimportance: should knowfreq 30%

answer

  1. except matches by subclass
  2. Widening is safe, narrowing is not
  3. The dangerous edit is invisible locally
  4. New class under an old one
  5. Assert the relations in a test

basics

~20 s

Only add downward. A new class that subclasses an already-documented type keeps every existing handler working; removing a class, dropping a base, or raising a different type from the same function silently unhandles code you cannot see.

solid answer

~50 s

Treat the hierarchy as API and reason from what `except` actually does: it matches by subclass. So **widening is safe and narrowing is not.** Adding a leaf under a documented type keeps old handlers catching it while new callers can be specific. Inserting an intermediate class between the package base and a leaf is also safe, because both ends still match. What breaks callers is invisible from your test suite: removing a class, dropping a secondary base such as `ValueError`, or changing which type a function raises for a given failure. The transitional trick when a rename is unavoidable is to make the new class inherit the old one so both clauses match, keep the old name importable, and warn on use. And the callers you break are your most careful ones — anyone who wrote `except Exception` notices nothing, while the team that handled your specific type gets an uncaught crash.

code

python · 17 lines
python
class QueueError(Exception):
    """Documented package base; never change what this sits under."""


class TimeoutExpired(QueueError):
    """Shipped in 1.0 and documented, so callers write except TimeoutExpired."""


# Adding a subclass is the safe move: old handlers keep catching it.
class WorkerTimeout(TimeoutExpired):
    pass


try:
    raise WorkerTimeout("worker did not report for 45 seconds")
except TimeoutExpired as exc:
    print("the 1.0 handler still catches", type(exc).__name__)

go deeper

for a junior

Take away the rule of thumb: except matches subclasses, so adding a class beneath an existing one is safe while removing or renaming one is not.

for a middle

Be able to name the concrete safe and unsafe edits, and explain why dropping a secondary base such as ValueError breaks callers even though the class still exists.

for a senior

Show how you would ship a necessary rename: the new class inheriting the old, the old name still importable, a deprecation warning on use, and a contract test pinning the relations.

for a principal

Argue the strategy: how many exception types a library should publish at all, who owns each compatibility bridge and when it is removed, and why the callers a hierarchy change breaks are your most careful ones.

### Start from what `except` matches on An `except` clause matches by `isinstance`, so a caller's handler fires for the named class **and every subclass of it**. Every rule about evolving a hierarchy falls straight out of that: - Making a raised exception's type *more specific* while keeping its old ancestry is invisible to callers. - Making it *less* specific, or moving it out from under something callers named, is a silent break. "Silent" is the operative word. Your test suite catches none of this, because your tests raise and catch your own current types. The damage lands in code you cannot see, and it manifests as an uncaught exception in someone else's production, not as a failed build. ### The safe moves **Add a leaf under a documented type.** The core tool. A queue library documented `TimeoutExpired`; you now want to distinguish a worker that never checked in from a job that ran too long. Add `class WorkerTimeout(TimeoutExpired)` and raise that. Every `except TimeoutExpired` in the wild keeps working; callers who want the distinction opt in. **Insert an intermediate class.** Putting `TransientError` between the package base and several existing leaves is safe: handlers on the base still match, handlers on the leaves still match, and a new group handler becomes possible. This is how a retry-oriented grouping gets added years after 1.0. **Attach context instead of a type.** Since Python 3.11 you can call `add_note()` on an exception to carry extra human-readable context up the stack. When the only need is a better message, a note costs nothing and adds no new class to the contract. **Add a new type for a genuinely new failure mode.** New code path, new leaf under the base. Existing callers who catch the base cover it automatically — the second reason the base class exists. ### The breaking moves, in order of how often people get caught **Dropping a base.** `class PageCountError(ConversionError, ValueError)` shipped in 1.0. Someone later decides the `ValueError` base is untidy and removes it. Every caller who wrote a generic input-validation handler now takes an uncaught exception. This is the single most common way a "cleanup" release breaks users, because the removed relationship is invisible in the class you are editing. **Changing which type a function raises.** The class still exists; the function now raises a sibling instead. Nothing looks wrong in the diff, and every caller's targeted handler stops firing. **Renaming or removing.** Obvious, but worth stating: an exception class name is imported by callers, so it is public API even when the module it lives in is not. **Narrowing the base itself.** Re-parenting the package base — from `Exception` to something narrower, or worse to `BaseException` — invalidates every handler in every consumer at once. ### When a break is genuinely necessary Make the new shape a superset of the old for one transitional release. If `TimeoutExpired` must become `WorkerTimeout`, define `class WorkerTimeout(TimeoutExpired)` and keep the old name importable, so both clauses match and old code is uninterrupted; emit a deprecation warning at the point the old name is *used*, not at import, so quiet consumers are not spammed. Multiple inheritance as a bridge — `class NewError(OldError, NewBase)` — also works and is legitimate, but each bridge is an MRO you must keep consistent and an `isinstance` result that surprises readers, so it is a device to remove on a schedule rather than a shape to live with. ### The judgement a lead actually owns **Publish as few types as you can defend.** Every documented class is a promise, and the honest way to keep promises is to make fewer of them. A base plus a handful of leaves that callers demonstrably branch on beats a taxonomy that mirrors your internal modules — that taxonomy encodes your implementation into someone else's `except` clauses, and then you cannot refactor. **Pin the contract in tests.** Write a test that asserts the relations themselves — that the leaves subclass the documented base, that the leaves that promise `ValueError` still have it, and that each public entry point raises only documented types. That is the only mechanism that turns an invisible break into a failing build, and it is cheap. **Weigh who you break.** A change that only affects `except Exception` users affects nobody; a change to a specific documented type hits the consumers who read your docs and handled your errors properly. Optimising for the careless callers at the expense of the careful ones is the wrong trade, and it is worth saying so explicitly when someone proposes a tidy-up. **Remember downstream operations.** Consumers group alerts and dashboards by exception type name. Renaming types is not only a code break; it silently splits somebody's error budget reporting into two series. That is an argument for adding rather than renaming even when a rename would read better.

  • Why is removing a secondary base such as ValueError so dangerous?
    Because the break is entirely outside your repository. Callers wrote `except ValueError` around your call and their handler simply stops matching; nothing in your tests, your type checker or your diff mentions them. The relationship was a promise made by the class statement, and deleting it is as breaking as deleting the class.
  • A function needs to raise something more specific than it does today. How do you ship that?
    Make the new type a subclass of the currently documented one and raise the subclass. Existing handlers keep matching by subclass, and callers who want the distinction start catching the new name. Nothing about that requires a transition period, which is why it is the preferred shape for almost every refinement.
  • How do you stop a future refactor from silently breaking the hierarchy?
    Test the contract directly: assert the subclass relations you have published, and assert that each public entry point raises only documented types. Those assertions are the only thing that converts an invisible downstream break into a red build, and they cost a few lines.
  • When is a clean break better than a compatibility shim?
    When the shim would encode a wrong model for years. A bridge class is a device with an owner and a removal date; if nobody will remove it, you have permanently doubled the surface. Weigh how many consumers actually catch the specific type against the cost of carrying two names through every future change.

You can add rooms to a building whose address people already have; renumbering the address strands everyone who wrote it down.

saying these in an interview costs you the question

  • Thinks removing a base class is a safe cleanup
  • Changes which type a function raises without notice
  • Assumes tests will catch a hierarchy break
  • Publishes a type per internal module
  • Renames exception classes for tidiness
  • Keeps compatibility bridges forever with no owner

context