2

Given a base exception type:

class MyModuleError(Exception):
    pass

Suppose we have code that explicitly raises it, using exception chaining:

def foo():
    try:
        #some code
    except (ZeroDivisionError, OSError) as e:
        raise MyModuleError from e

Now, in the calling code...

try:
    foo()
except MyModuleError as e:
    # Now what?

how can I idiomatically write the except clause, so that the exception handling depends on the __cause__ (chained exception)?

I thought of these approaches:

a) using type(e) like:

    # filter here
    t=type(e.__cause__)
    if t is ZeroDivisionError:
        doStuff()
    elif t is OSError:
        doOtherStuff()
    else:
        raise

b) using isinstance() like:

    # filter here
    if isinstance(e.__cause__, ZeroDivisionError):
        doStuff()
    elif isinstance(e.__cause__, OSError):
        doOtherStuff()
    else:
        raise

c) re-raising like:

    # filter here
    try:
        raise e.__cause__
    except ZeroDivisionError:
        doStuff()
    except OSError:
        doOtherStuff()
    except:
        raise e    #which should be the "outer" exception
Karl Knechtel
  • 62,466
  • 11
  • 102
  • 153
calestyo
  • 327
  • 2
  • 7
  • 2
    Option b) seems best to me. a) doesn't account for subtypes, and c) puts extra junk in the traceback – wim Jul 14 '23 at 01:37
  • I just noted, that method `wrapper3` seems to have a memory leek (which I don't understand why)... If I call it with `-n 100000000`, the python process eats up the whole system memory (62GB) until the kernel kills it ^^ – calestyo Jul 14 '23 at 01:46
  • @wim. Was about to say the same thing – Mad Physicist Jul 14 '23 at 01:48
  • @wim That with the subtype I don't understand, If I take e.g. `PermissionError` which is derived from `OSError`, then both (a) and (b) seem to always give true identity only (`PermissionError is OSError`, `OSError is PermissionError`, `isinstance(PermissionError, OSError)` and `isinstance(OSError, PermissionError)` are all `False`) – calestyo Jul 14 '23 at 01:51
  • Edit: Stupid me... need to take concrete instances, of course. Then `isinstance()` considers the subtyps. – calestyo Jul 14 '23 at 02:02
  • 1
    Yeah for two types you want `issubclass(PermissionError, OSError)` not isinstance. – wim Jul 14 '23 at 02:03
  • 1
    Basically, (a) and (b) are the two reasonable ones here, depending on precisely the semantics you want for the runtime type checking (checking precisely the type or using `isinstance` which accounts for subtypes (and overriden instance checks e.g. from `__subclasshook__`, although taht is more of an edge case) – juanpa.arrivillaga Jul 14 '23 at 02:32
  • @juanpa.arrivillaga I think that would be the chosen answer for this question! – calestyo Jul 14 '23 at 03:06

3 Answers3

2
try:
    foo()
except MyModuleError as e:
    # filter here
    if isinstance(e.__cause__, ZeroDivisionError):
        doStuff()
    elif isinstance(e.__cause__, OSError):
        doOtherStuff()
    else:
        raise

in general, using isinstance() is often considered more flexible and recommended over using type() for type checking.

Phoenix
  • 1,343
  • 8
  • 10
  • Hmm, though it does seem (a tiny bit) slower here... do you have perhaps any concrete reasons? – calestyo Jul 14 '23 at 01:55
  • @calestyo Using isinstance() can make your code more maintainable and future-proof. If you later introduce new subclasses or types that should be handled in a specific way, you don't need to modify the type checks if you're using isinstance() with inheritance. However, with type(), you would need to update the type check conditions explicitly. – Phoenix Jul 14 '23 at 01:57
  • Hmm then I think the answer should be that it's either (a) or (b), depending on what exactly one wants (subclasses considered or not). Which or exception handling may actually both be interesting... e.g. if `FileNotFoundError` occurs I may just retry (maybe it shows up), ... but if OSError occurs, it may not make sense.Okay bad example.. but surely there are ones where it's the other way round. – calestyo Jul 14 '23 at 02:03
  • Any concrete reason against (c)? Apart from the memory leak? – calestyo Jul 14 '23 at 02:06
  • 1
    @calestyo that it's a total abuse of exceptions, for one. – juanpa.arrivillaga Jul 14 '23 at 02:28
2

The re-raising is not recommended. In general, it is not idiomatic to raise something deliberately so that it can be immediately caught1. It's also less performant (exception handling can involve quite a bit of overhead when the exception is actually raised), and creates a reference cycle between the exception object and the current stack frame, which in the reference implementation of Python will leak memory when the auxiliary garbage collector is disabled. (This is why exception names created with as are explicitly deleted after the except block in 3.x.)

On general principle, isinstance is the preferred means of type-checking rather than comparing type results directly, because isinstance automatically takes subtyping into account. The normal functionality of except accounts for subtyping (e.g. except IOError: will catch a FileNotFoundError, which is normally desirable); it stands to reason that, in any normal circumstance, type-checking of the chained exception should do this as well.

There is no explicit built-in functionality for this; therefore, approach b) is recommended here.

1Yes, for loops are internally implemented this way, using StopIteration. That's an implementation detail, and user code isn't intended to look like it's doing such things.

Karl Knechtel
  • 62,466
  • 11
  • 102
  • 153
0

At least the performance part of my question can be answered quite easily.

Assuming the following example code:

#!/usr/bin/python3

import timeit
import argparse


parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("-n", "--iterations", type=int, default=1)
args = parser.parse_args()



class MyModuleError(Exception):
    pass

def foo():
    try:
        f = 1/0
    except (ValueError, ZeroDivisionError) as e:
        raise MyModuleError from e

def wrapper1():
    try:
        foo()
    except MyModuleError as e:
        t = type(e.__cause__)
        if t is ZeroDivisionError:
            pass
        elif t is OSError:
            pass
        else:
           raise

def wrapper2():
    try:
        foo()
    except MyModuleError as e:
        if isinstance(e.__cause__, ZeroDivisionError):
            pass
        elif isinstance(e.__cause__, OSError):
            pass
        else:
           raise

def wrapper3():
    try:
        foo()
    except MyModuleError as e:
        try:
            raise e.__cause__
        except ZeroDivisionError:
            pass
        except OSError:
            pass
        except:
           raise e    #which should be the "outer" exception



t = timeit.timeit(wrapper1, number=args.iterations)
print(f"wrapper1: {t}")
t = timeit.timeit(wrapper2, number=args.iterations)
print(f"wrapper2: {t}")
t = timeit.timeit(wrapper3, number=args.iterations)
print(f"wrapper3: {t}")

Gives, as of CPython 3.11.4 the following results:

$ ./chained-exception-handling.py -n 10000000
wrapper1: 4.373930287983967
wrapper2: 4.534742605988868
wrapper3: 7.319078870001249

Actually, I'm a bit disappointed... I thought the method of re-raising the inner exception might be the most "pythonic" one, but that's also the slowest (will, kinda fits Python, doesn't it?! O;-) ).

calestyo
  • 327
  • 2
  • 7
  • See my own comment to the original question.. `wrapper3` (for some reason I don't understand) has a memory leak and gets the process killed by the kernel if `-n` is sufficiently high. – calestyo Jul 14 '23 at 01:54
  • re-raising an exception to purposefully catch it is certainly *not* pythonic. – juanpa.arrivillaga Jul 14 '23 at 02:22
  • 1
    So, when you catch an exception, the error carries around a reference to the stack frame, but the stack frame references the error. This causes a reference cycle which can lead to memory leaks. Normally, as a special case, the `e` reference is actually deleted, but re-raising it like that keeps it alive. CPython uses reference counting as its main memory-management strategy, but there is an auxiliary gc that handles references cycles, but I'm pretty that `timeit.timeit` disables the `gc`! So this would explain the memory leak – juanpa.arrivillaga Jul 14 '23 at 02:26
  • @juanpa.arrivillaga So... you'd say this is not really a bug - or should I report it upstream? – calestyo Jul 14 '23 at 02:30
  • 1
    This is not a CPython bug, no. I suspect it's just an issue with `timeit.timeit`, but it isn't a bug there either, since the fact that it disables the auxiliary garbage collector [is documented](https://docs.python.org/3/library/timeit.html#timeit.Timer.timeit). One thing to test would be running `wrapper3()` in a loop outside of timeit and seeing if a sufficiently high n reproduces the out of memory error. – juanpa.arrivillaga Jul 14 '23 at 02:34
  • 1
    Or alternatively, as the docs suggest, add `'gc.enable()'` as the `setup` string – juanpa.arrivillaga Jul 14 '23 at 02:42