In DRY Python code, when should I catch/raise exceptions? Given python's principle of duck-typing, when is it best to raise exceptions?

In a simplified case below, say I want to code a couple functions to return booleans based on an input object product. Rather than catching an exception if an unexpected object was entered, I'm splitting functionality to keep things DRY. 

However, should I also be catching the raised exception in functions that call the abstracted code? 

For the options below, which would be the better approach? Are there better approaches still? 

Option 1

TAX_RATE = 0.2

def calculate_price_after_tax(product):
    """Returns Decimal"""
    try:
        net_price = product.price
    except AttributeError:
        raise TypeError('Expected an input of type Product, but received: {0}.'.format(type(product))
    return net_price * TAX_RATE

def is_price_above_100(product):
    """Returns bool"""
    return calculate_price_after_tax(product) &gt; 100

def is_price_above_200(product):
    """Returns bool"""
    return calculate_price_after_tax(product) &gt; 200


Option 2

TAX_RATE = 0.2

def calculate_price_after_tax(product):
    """Returns Decimal"""
    try:
        net_price = product.price
    except AttributeError:
        raise TypeError('Expected an input of type Product, but received: {0}.'.format(type(product))
    return net_price * TAX_RATE

def is_price_above_100(product):
    """Returns bool"""
    try:
        calculate_price_after_tax(product) &gt; 100
    except TypeError as e:
        raise TypeError(e)

def is_price_above_200(product):
    """Returns bool"""
    try:
        calculate_price_after_tax(product) &gt; 200
    except TypeError as e:
        raise TypeError(e)
Your Option 1 and Option 2 are almmost exactly the same (except that Option 2 is more confusing)

This is because if you don't catch an exception, it will just rise to the top, where it will halt your program. Catching and immediately re-raising it is (almost) the same as never catching it at all.

The only difference can be seen here:

In [1]: def f(a, b):
   ...:     return a / b
   ...: 

In [2]: def g(a, b):
   ...:     try:
   ...:         return f(a, b)
   ...:     except ZeroDivisionError as e:
   ...:         raise ZeroDivisionError(e)


When calling g(2, 0), the traceback shows us the place where the error was last raised, which is in the function g:

In [3]: g(2, 0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
&lt;ipython-input-3-9920cf4d7d78&gt; in &lt;module&gt;()
----&gt; 1 g(2, 0)

&lt;ipython-input-2-a48ddeb59e34&gt; in g(a, b)
      3         return f(a, b)
      4     except ZeroDivisionError as e:
----&gt; 5         raise ZeroDivisionError(e)
      6 

ZeroDivisionError: integer division or modulo by zero


When defining a third function h, that does not re-raise the exception, this does not happen and we see the full context of calling f from within h:

In [4]: def h(a, b):
   ...:     return f(a, b)
   ...: 

In [5]: h(2, 0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
&lt;ipython-input-5-b455367d475f&gt; in &lt;module&gt;()
----&gt; 1 h(2, 0)

&lt;ipython-input-4-7bfa64a165c7&gt; in h(a, b)
      1 def h(a, b):
----&gt; 2     return f(a, b)

&lt;ipython-input-1-44ecddb27ac3&gt; in f(a, b)
      1 def f(a, b):
----&gt; 2     return a / b

ZeroDivisionError: integer division or modulo by zero


Finally note that if you use just a raise (without the explicit exception type and passing it the exception), it will also behave like the function h:

In [6]: def k(a, b):
   ...:     try:
   ...:         return f(a,b)
   ...:     except ZeroDivisionError:
   ...:         raise
   ...:     

In [7]: k(2, 0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
&lt;ipython-input-7-545f9b93e361&gt; in &lt;module&gt;()
----&gt; 1 k(2, 0)

&lt;ipython-input-6-5891fde4d79f&gt; in k(a, b)
      1 def k(a, b):
      2     try:
----&gt; 3         return f(a,b)
      4     except ZeroDivisionError:
      5         raise

&lt;ipython-input-1-44ecddb27ac3&gt; in f(a, b)
      1 def f(a, b):
----&gt; 2     return a / b

ZeroDivisionError: integer division or modulo by zero


So re-raising the exception in this way just obfuscates where the exception originally occurred. This can be a good or a bad thing, but I would not recommend using it unless that is your intention.