skip to content

Translating Exceptions Across Layers

Turning a driver, transport or parser error into a domain error at the layer that owns the abstraction, while raise from keeps the original visible. Interviewers ask which layer should catch.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why translate a third-party library's exception into your own exception class at a module boundary?

level: juniorimportance: must knowfreq 55%

answer

  1. Think about the caller's import list
  2. The raised type is part of the contract
  3. Swapping the library should not break callers
  4. Wrap where the abstraction lives
  5. `from exc` keeps the original visible

basics

~20 s

So callers depend on your abstraction rather than on the library. If a database driver's error class escapes your function, every caller must import that driver to catch it, and replacing the driver breaks all of them.

solid answer

~50 s

A Python function has no `throws` clause, but the exception types it lets escape are still part of its contract. If a storage helper lets a database driver's `sqlite3.DatabaseError` out, every caller has to `import sqlite3` just to write an `except` clause, so the dependency you hid behind the function reappears in the caller's imports and swapping the driver becomes a breaking change. The fix is to catch the library type in the one module that owns the driver and raise a domain type instead - `raise StorageError(...) from exc` - which keeps the original attached as `__cause__` for logs and debugging. Translate where the abstraction lives, map distinguishable conditions onto distinct domain types rather than collapsing everything into one, and do not wrap errors your own code raised for reasons the caller already understands.

code

python · 19 lines
python
import sqlite3


class StorageError(Exception):
    """Raised by the storage layer; callers never import sqlite3."""


def load_rows(conn):
    try:
        return conn.execute("SELECT body FROM reports").fetchall()
    except sqlite3.DatabaseError as exc:
        raise StorageError("could not read report rows") from exc


conn = sqlite3.connect(":memory:")
try:
    load_rows(conn)
except StorageError as err:
    print(type(err).__name__, "caused by", type(err.__cause__).__name__)

go deeper

for a junior

Be ready to say plainly who is hurt when a library's exception escapes: the caller, who now has to import that library to catch it. Know that raise MyError(...) from exc is how you translate without losing the original.

for a middle

Explain the mechanics: which module does the catching, why you catch the library's base error class rather than one leaf type, and how __cause__ keeps the driver's message available for logs while it stays out of the caller's code.

for a senior

Show judgement about how many domain types to invent - one per distinct caller reaction, not one per library error - and about when wrapping adds nothing, such as bugs and well-known stdlib types. Talk about how you stop the driver import from spreading.

for a principal

Own the policy: which boundaries in the system are worth a translation layer, what it costs a team to maintain the mapping, and how you avoid a wrapper that leaks vendor-specific attributes and therefore protects nothing.

## The raised type is part of the signature Python has no checked exceptions and no `throws` clause, so it is easy to forget that the exception types a function lets escape are part of what its callers must code against. If a helper called `load_rows(conn)` allows a database driver's `sqlite3.DatabaseError` to propagate, then every caller that wants to handle a storage failure has to `import sqlite3` and name that class in an `except` clause. The dependency you believed was encapsulated behind a function is now in the caller's import list - not because the caller talks to the database, but because it has to catch the database's errors. That is the concrete harm. Everything else follows from it: * **Replacing the library becomes a breaking change.** Swap the driver and every `except` clause across the codebase names a class that no longer gets raised. The failures are silent: the code still imports, still runs, and simply stops catching. * **Callers are pushed to over-catch.** Faced with an unnameable error surface, people write `except Exception` and lose every distinction that mattered. * **Layers above learn vocabulary they should not have.** A report-building routine that mentions a driver's error class has quietly become a database-aware module. ## What translation looks like Catch the library's type in the single module that owns it, and raise your own: ```python class StorageError(Exception): pass def load_rows(conn): try: return conn.execute("SELECT body FROM reports").fetchall() except sqlite3.DatabaseError as exc: raise StorageError("could not read report rows") from exc ``` Two details carry most of the value. First, the `except` clause names the driver's **base** error class, not one leaf type, so a new failure mode in the driver still comes out as a domain error rather than escaping raw. Second, `from exc` sets the new exception's `__cause__` to the original, so nothing is destroyed: the traceback shows both errors, and an operator reading the log still sees the driver's message underneath yours. ## Translation is a mapping, not a rename The common failure is collapsing everything into one type. If `StorageError` is raised for a missing row, a bad credential, a syntax error in your own SQL and a dead network link alike, the caller can do nothing with it except log and give up - which is exactly what it could have done with `except Exception`. A useful translation preserves the distinctions the caller can act on, and only those. A missing report is worth its own type because a caller can substitute a default; a corrupt store is worth its own type because a caller should abort. Anything the caller cannot act on differently belongs under one shared type. That is also the test for how many domain types to invent: not "how many error classes does the library have", but "how many different reactions does my caller have". ## When *not* to wrap Wrapping is a cost, and there are cases where it buys nothing: * **Errors your own code raised for the caller's benefit.** If you validate an argument and raise `ValueError`, wrapping it in `ConfigError` adds a layer without adding information. * **Well-known stdlib types the caller already handles sensibly.** `OSError`, `TimeoutError` and `KeyError` are shared vocabulary; hiding them behind a domain type is sometimes right at a hard boundary and often just noise inside one application. * **Bugs.** A `TypeError` or `AttributeError` from your own module is a defect, not a domain condition. Wrapping it disguises a stack trace you want to see intact. * **Every layer.** Translating once at the boundary that owns the abstraction is the point; translating again one call up produces nested wrappers whose innermost useful message is three `__cause__` links away. ## The shape of the wrapper class Keep the domain exception cheap: a class inheriting from `Exception` with a readable message. If a caller needs a fact to make a decision - which report was missing, which key failed - put it in an attribute rather than forcing the caller to parse the message string. Resist putting driver-specific fields on it (a vendor error number, a cursor object): the moment the wrapper carries the library's vocabulary in its attributes, callers start depending on that, and the abstraction has leaked through the class you invented to protect it. ## Why interviewers ask this It is a compact test of whether a candidate thinks about a function's public surface at all. The weak answer is "so the error message is nicer". The strong answer names the caller: who has to import what in order to catch this, and what breaks when the library underneath changes.

  • When would you deliberately let a library's own exception type escape your function?
    When the library is effectively part of your public vocabulary and the caller is expected to know it - a thin utility around a stdlib module, for instance, where hiding `OSError` behind a new class only makes the caller guess. Also when the boundary is internal and short-lived: two modules inside one small application, both owned by the same people, gain little from a translation layer that has to be maintained. The test is whether the library could plausibly be replaced without the caller caring.
  • Should the domain exception repeat the original error's message text?
    It should say what failed in your own vocabulary and let `__cause__` carry the library's wording. Copying the driver's message into your message duplicates it in the traceback and tempts callers to parse it. Add only the facts your layer knows and the library does not - which report, which configuration key - preferably as attributes rather than embedded in the string.
  • How would you keep a driver's exception class from creeping back into upper layers over time?
    Make the boundary visible: only the adapter module imports the driver, and a small test asserts that calling the public function against a broken store raises the domain type. An import-graph check that fails when any module outside the adapter package imports the driver catches the regression at review time rather than in production.

A restaurant tells you "we are out of the fish", not "the supplier's van broke down on the motorway". You get the fact you can act on; who supplies the fish stays the kitchen's business.

saying these in an interview costs you the question

  • Thinks wrapping is only about a friendlier error message
  • Catches bare Exception and re-raises one generic AppError
  • Believes wrapping discards the original exception
  • Lets the driver's exception class appear in the public docstring
  • Wraps again at every layer, nesting three error types
  • Invents one domain exception class per library error class

context

open as a page

When wrapping a library error, what does `raise MyError(...) from exc` preserve?

level: middleimportance: must knowfreq 50%

basics

~20 s

The original exception, attached to the new one as its __cause__. Both errors are then printed, so the domain type reaches the caller while the library's real message and stack frames stay available to whoever reads the log.

open as a page

Which layer should translate a database driver's error in a nightly report generator with a storage adapter, a service layer and a CLI?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The storage adapter - the only module that imports the driver. It maps driver failures onto domain error types; the service layer handles those types without knowing the driver exists, and the CLI turns whatever escapes into a message and an exit status.

open as a page

When is translating every library exception into a domain hierarchy the wrong call for a 4-person team?

level: principalimportance: should knowfreq 33%

basics

~20 s

When the mapping costs more than the coupling it removes: one application, one team, one library that will not be replaced. Translation earns its keep at boundaries someone else calls or where the dependency is genuinely swappable - not at every internal module edge.

open as a page