Skip to content

← Failure by Design step 4 of 18

Medium Primitives

Exception chaining: raise ... from

RuntimeError: request failed with no __cause__ tells you nothing. The same error chained to a ConnectionResetError tells you everything: which layer broke, and whether it is worth retrying. Chaining is the cheapest debuggability you will ever buy, and Python gives you three distinct behaviours:

  • Implicit — raise anything inside an except block and the interpreter sets __context__ to whatever you were handling. The traceback prints “During handling of the above exception, another exception occurred”. You get this for free; it is often noise.
  • Explicitraise NewError(...) from original sets __cause__. The traceback prints “The above exception was the direct cause of the following exception”, and every log aggregator and error tracker follows the link. This is what you want when translating an error across a boundary.
  • Suppressedraise NewError(...) from None clears the display chain. Correct in exactly two situations: the original leaks an implementation detail your caller must not couple to, or it contains a secret. Reaching for it because the traceback is “ugly” is how incidents become unsolvable.

Two more tools worth internalising: a bare raise inside an except block re-raises the current exception with its original traceback (writing raise exc instead resets the traceback to this line — a real loss of information), and exc.add_note("...") (3.11) attaches context to an exception that is already in flight.

What to build

def translate[T](
    fn: Callable[[], T],
    mapping: Mapping[type[Exception], type[AppError]],
) -> T:

Call fn(). If it returns, return its value.

If it raises an Exception, walk type(exc).__mro__ in order and take the first class that is a key in mapping. MRO order is what makes “most specific wins” fall out for free: for a ZeroDivisionError the MRO is ZeroDivisionError -> ArithmeticError -> Exception -> BaseException -> object, so a mapping containing both ZeroDivisionError and ArithmeticError picks the former. Raise mapping[base](str(exc)) from the original.

If nothing in the MRO is a key, the exception is not yours to translate: re-raise it untouched with a bare raise, preserving its traceback.

The module already gives you RAISABLE (name to builtin exception class) and APP_ERRORS (name to AppError subclass), plus the AppError hierarchy: NetworkError, DataError, AuthError.

The probe

def solve(
    raises: str | None,
    message: str,
    mapping: Mapping[str, str],
) -> dict[str, object]:

Build fn so that it raises RAISABLE[raises](message) when raises is not None, and returns the integer 7 otherwise. Build the class-keyed mapping from the string-keyed one. Call translate, and report.

On the success path return exactly:

{"outcome": "returned", "value": <what fn returned>}

On the failure path catch at the boundary and return exactly:

key value
"outcome" "raised"
"type" type(exc).__name__
"app_error" isinstance(exc, AppError)
"message" str(exc)
"cause_type" type(exc.__cause__).__name__, or None
"cause_message" str(exc.__cause__), or None
"context_type" type(exc.__context__).__name__, or None
"cause_is_context" True when __cause__ is not None and is the same object as __context__

That last key is not a trick question. Raising from exc inside an except exc block sets both attributes, to the same object — the explicit cause and the implicit context coincide. Seeing that once is worth a paragraph of prose.

Typing notes

from accepts BaseException | None, so mypy is happy with from exc. Reading exc.__cause__ gives you BaseException | None, which means you cannot call type(...).__name__ on it until you have proved it is not None — the is not None check is load-bearing, not decorative.

translate is generic in T with PEP 695 syntax (def translate[T](...), Python 3.12+). Getting that right is what makes translate(fn, mapping) return int rather than Any at the call site.

Loading visualization…