Is exception-propagating decorator a good pattern?
Asked Answered
M

1

6

Well-defined custom exceptions are often more informative than builtin ones; e.g. AgeError more so than ValueError. So in general I try to use the former when I can. But as a consequence of this, my code is littered with lots of raise foo from bar boilerplate just to propagate a custom exception. Here is an example of what I mean. Without using custom exceptions, I'd just write:

class Person:
    def set_age(self, age_as_string):
        self.age = int(age_as_string)

This may raise either TypeError or ValueError, but since the caller will handle it, a one-liner is just fine.

But to use custom exceptions, I need boilerplate:

class AgeError(Exception):
    pass

class Person:
    def set_age(self, age_as_string):
        try:
            self.age = int(age_as_string)
        except (TypeError, ValueError) as e:
            raise AgeError from e

This is more informative from the caller's point of view, but costs 300% more code (just counting the method body) and obscures the main business of set_age.

Is there a way to have the best of both worlds? I tried googling for solutions but even the problem doesn't seem to be much discussed at all. The solution I eventually come to is use an exception-propagating decorator, which is trivial to write thanks to the wonderful contextlib (and only a little less trivial if you need to implement it by hand):

from contextlib import contextmanager

@contextmananer
def raise_from(to_catch, to_raise):
    try:
        yield
    except to_catch as e:
        raise to_raise from e

Now I need only one-extra line, which doesn't obscure the business logic, and even makes the error-handling logic somewhat more obvious (and it's smart-looking):

class Person:
    @raise_from(to_catch=(TypeError, ValueError), to_raise=AgeError)
    def set_age(self, age_as_string):
        self.age = int(age_as_string)

So I'm quite happy with this solution. But since it's unlikely that there still exists any unsolved problem with an easy solution like this, I'm worried that I may be missing something. Are there disadvantages with using the raise_from decorator that I haven't considered? Or is the very need to reduce the raise foo from bar boilerplate an indication that I'm doing something wrong?

Meath answered 6/2, 2016 at 4:28 Comment(0)
A
3

There's nothing wrong with your raise_from context manager, but the slight issue worth pointing out is that you're using it as a function decorator, which applies the exception mapping logics to the entire body of the function. This can be problematic when the function is not a one-liner like the set_age method in your example, since multiple statements in the function may raise the same exception for different reasons.

For example, you definitely don't want AgeError to be raised when it's caused by int(weight_as_string):

class Person:
    @raise_from(to_catch=(TypeError, ValueError), to_raise=AgeError)
    def __init__(self, age_as_string, weight_as_string):
        self.age = int(age_as_string)
        self.weight = int(weight_as_string)

So to keep the scope of the exception handler as narrow as possible, it is generally better to use raise_from as a context manager instead of a decorator:

class Person:
    def __init__(self, age_as_string, weight_as_string):
        with raise_from(to_catch=(TypeError, ValueError), to_raise=AgeError):
            self.age = int(age_as_string)
        with raise_from(to_catch=(TypeError, ValueError), to_raise=WeightError):
            self.weight = int(weight_as_string)
Alexalexa answered 19/2 at 9:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.