Skip to content

← Error Handling with Exceptions, Retries and Warnings step 7 of 20

Medium Primitives

Translate Library Errors into Your Own

Your app calls a library that raises errors such as ConnectionResetError or ValueError. Code that calls your app should see only your app's errors, whichever library you use. Write a helper that turns selected library errors into app errors and keeps the original attached with raise ... from.

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


translate(lambda: 7, {ValueError: DataError})         # returns 7
translate(lambda: int("x"), {ValueError: DataError})  # raises DataError

def translate[T](...) -> T says the function returns whatever type fn returns, and Callable[[], T] is a function that takes zero arguments. Use this syntax as shown, and pass mypy strict.

What translate does

  • Call fn once. If it returns, return its result unchanged.
  • If it raises an Exception, look through type(exc).__mro__ in order, and use the first class that is a key in mapping. Construct the mapped class with str(exc), and raise it from the original exception.
  • If nothing matches, re-raise the original with a bare raise. That keeps the same exception object, its traceback and any chain it already has.
  • Catch only Exception. KeyboardInterrupt, SystemExit and the other exceptions outside the Exception family pass through untouched.

type(exc).__mro__, the method resolution order, lists the class and every class it inherits from, starting with the class itself and ending at object. For ZeroDivisionError it is (ZeroDivisionError, ArithmeticError, Exception, BaseException, object).

So an entry for the exception's own class comes before an entry for a parent. If mapping has both ZeroDivisionError and ArithmeticError, a ZeroDivisionError uses its own entry. The MRO alone decides, whatever order the keys in mapping have. If a class has several parents, still follow the MRO as Python gives it.

What solve reports

Keep AppError, its subclasses NetworkError, DataError and AuthError, and the tables RAISABLE and APP_ERRORS, which map names to classes. solve(raises, message, mapping) gets names, and every name is a key in one of the tables.

  • Turn mapping into a mapping from class to class with the two tables.
  • Build a function that raises RAISABLE[raises](message), or returns 7 when raises is None. Call it through translate.
  • If it returns, report {"outcome": "returned", "value": 7}.
  • If it raises, report these keys:
Key Value
outcome "raised"
type the final exception's class name
app_error whether the final exception is an AppError
message str(exc) for the final exception
cause_type the class name of __cause__, or None
cause_message str() of __cause__, or None
context_type the class name of __context__, or None
cause_is_context True only when __cause__ is an exception and is the same object as __context__

A mapped error has the original as both __cause__ and __context__. An unmapped error in this report has neither, because fn raises it outside any except block.

Edge cases

  • str(KeyError("missing")) is 'missing', with quotes. Report str(exc) instead of the message you passed in.
  • Check for None before you take a class name. type(None).__name__ is "NoneType", and the report wants None.