Elegant Exception Handling

Eyal Trabelsi

About Me 🙈

  • Software Engineer at Salesforce 👷
  • Big passion for python and data 🐍🤖

Restaurant Recommendation 🍔

In [4]:
! pip install typeguard rollbar returns tenacity icontract > /dev/null 2>&1
In [5]:
import contextlib
import json
import icontract
import logging
import pathlib
import os
from typing import Union

import requests
from typeguard import typechecked
In [3]:
def get_relevant_restaurants(user):
    base_url = "https://en.wikipedia.org/wiki"
    return requests.get(f"{base_url}/{user}").content

def get_config(path):
    with open(path, 'r') as json_file:
        return json.load(json_file)

def pick_best_restaurants(restaurants):
    pass
In [4]:
def get_restaurant_recommendation(path):
    config = get_config(path)
    user = config["user"]
    candidates = get_relevant_restaurants(user)
    pick_best_restaurants(candidates)

We Can Proud of Ourselves 💃

  • Implemented restaurant recommendation 💪
  • Clean code 💄

Exception Handling? Why?! 🤨

  • Errors are everywhere 🙈
  • Hardware can fail 🌲
  • Software often fail 🚪

Unexceptable 😡

Lesson 1: We want to build a fault tolerant system.

Exception Handling to the Rescue 👨‍🚒

Exceptions Anatomy from bird's eye view 🐦

  • Exception message 💬
  • Exception traceback 👻
  • Exception type 🍅🍇🍆

Exception Types 🍅🍇🍆

  • Helps distinguish between different exceptions
  • Hierarchical nature
  • Dozens of built-in exceptions
  • Builtin and Custom exceptions

Naive Approach for Exception Handling👶

  • Catch all exceptions 🙈
  • Log all exceptions 📝
  • "Clean and safe" version 😈
In [10]:
def get_restaurant_recommendation(path):
    try:
        config = get_config(path)
        user = config["user"]
        candidates = get_relevant_restaurants(user)
        pick_best_restaurants(candidates)
    except BaseException:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise BaseException

There are problems lurking around🐲

  • Unintentional exceptions being caught 😧
  • KeyboardInterrupt as we want the user to be able to kill the program.
  • SynatxError as we want our code to be valid.
  • Exceptions are not distinguishable 😵
  • Not safe 🔓
  • the invoker of this function can't really destinguise between the diffrent types of errors and allow to recover from certain expected issues.
  • For example, if we have flaky internet i would like to retry, but if a file is actually missing I dont.
  • generaly it’s better for a program to fail fast and crash than to silence the error and continue running the program.

  • The bugs that inevitably happen later on will be harder to debug since they are far removed from the original cause.

  • Just because programmers often ignore error messages doesn’t mean the program should stop emitting them.

  • Unfortunately very common 😱

Naive approach for exception handling won't do.

Take 2: Exception Handling 🎬

  • Should not catch all exceptions ☝
  • Recover when possible 🔧
  • Propogated exceptions should be distinguishable 👯
In [24]:
def get_restaurant_recommendation(path):
    try:
        config = get_config(path)
        user = config["user"]
    except FileNotFoundException:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    except JSONDecodeError:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    except KeyError:
        user = "default_user"
    candidates = get_relevant_restaurants(user)
    pick_best_restaurants(candidates)

Lesson 2: Catch relevant exceptions only.

Lesson 3: Different propogated exceptions should be distinguishable.

A Bit of Mackup💄

In [9]:
def get_restaurant_recommendation(path):
    try:
        config = get_config(path)
        user = config["user"]
    except FileNotFoundException:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    except JSONDecodeError:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    except KeyError:
        user = "default_user"
    candidates = get_relevant_restaurants(user)
    pick_best_restaurants(candidates)
  • First since we handle both FileNotFoundException and JSONDecodeError in the same manner they can "share the except block" as except clause may name multiple exceptions as a parenthesized tuple.
  • Secondly we can use else clause which occur when the try block executed and did not raise an exception.

  • Thirdly, we use dictionary builtin function get which allow us to define default values.

Lesson 4: Use python syntax to the fullest

Suppressing Exceptions 🤫

  • There is another common flow for exception handling
  • i want to cover which is suppressing exceptions using suppress
  • supported from python>=3.5
In [25]:
def run_unstopable_animation():
    pass

try:
    os.remove('somefile.pyc')
except FileNotFoundError:
    pass

try:
    run_unstopable_animation()
except KeyboardInterrupt:
    pass

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove('somefile.pyc')
    
from contextlib import suppress

with suppress(KeyboardInterrupt):
    run_unstopable_animation()       

Our code is not elegant 😭

  • Dominated by exception handling
  • Business logic is not clear
  • Code become hard to maintain

Lesson 5: Error handling should not obscures business logic

- Error handling is important, but we should strives to make our job easier.
  • as the zen of python state "If the implementation is hard to explain, it's a bad idea."

Take 3: Exception Handling 🎬

  • Separate business logic from exception handling code ✂
  • Handled exceptions in other layer 📚

The "perfect" code:

In [5]:
def get_restaurant_recommendation(path):
    config = get_config(path)
    user = get_config.get("user", "default_user")
    candidates = get_relevant_restaurants(user)
    pick_best_restaurants(candidates)    

In [6]:
def get_config(path):
    with open(path, 'r') as json_file:
        config = json.load(json_file)
        return config
    
def get_restaurant_recommendation(path):
    try:
        config = get_config(path)
    except (FileNotFoundException, JSONDecodeError):
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    else:
        user = config.get("user", "default_user")
        candidates = get_relevant_restaurants(user)
        pick_best_restaurants(candidates)    

Lesson 6: Pick the right abstraction level to handle exceptions

Are we completly safe now? 👷

Silent Errors 🔇

  • Does not crash code 😠
  • Delivers incorrect results 😠😠
  • Much harder to detect, Makes matter worse 🤬

Validations 🆗

  • Output/Input types/values
  • Postconditions/Preconditions
  • Side-effects/Invariants

Lesson 7: validate, and fail fast !

Tools for validation 🔨

  • Vanilla Exceptions 🍧
  • Type Hints 🔍
  • Contract Testing Libraries 📜

Vanilla Exceptions 🍧

In [11]:
def get_user(path):
    if isinstance(path, (str, pathlib.PurePath)):
        raise TypeError(f"path has invalid type: {type(path).__name__}")
        
    with open(path, 'r') as json_file:
        try:
            config = json.load(json_file)
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY INFORMATIVE INFORMATION")
            raise
        else:
            user = config.get("user","default_user")
            if isinstance(user, str):
                raise TypeError(f"user has invalid type: {type(user).__name__}")
            return user
  • Can validate everything ✅
  • On runtime ✅ but not compile time ❌
  • Not clean ❌

Why not assertions ? ❌

  • Raises the wrong exception type 😮
  • Can be compiled away 😥

Type Hints 🔍

In [36]:
@typechecked
def get_user(path: Union[str, pathlib.PurePath]) -> str:
    with open(path, 'r') as json_file:
        try:
            data = json.load(json_file)
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY INFORMATIVE INFORMATION")
            raise
        else:
            user = data.get("user","default_user")
            return user
  • Can validate input/output types ✅ But not other validation ❌
  • On runtime and compile time ✅
  • Clean and elegant ✅

Contract Testing Libraries 📜

In [7]:
@icontract.require(lambda path: path.startswith("s3://"), "path must be valid s3 path")
def get_user(path):
    with open(path, 'r') as json_file:
        try:
            data = json.load(json_file)
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY INFORMATIVE INFORMATION")
            raise
        else:
            user = data.get("user","default_user")
            return user
  • All the validations are supported ✅
  • On runtime ✅ but not compile time ❌
  • Clean and elegant ✅
  • No mature/maintained option ❌

There are still problems lurking 🐉

In [21]:
def get_relevant_restaurants(user):
    base_url = "cool_restaurants.com"
    resp = requests.get(f"{base_url}/{user}")
    resp.raise_for_status()
    return resp.json()

App might "live" in Unstable Environment 🤪

  • Your network might be down 😑
  • The server might be down 😣
  • The server might be too busy and you will face a timeout 😭
In [40]:
def get_relevant_restaurants(user):
    base_url = "cool_restaurants.com"
    
    allowed_retries = 5
    for i in range(allowed_retries):
        try:
            resp = requests.get(f"{base_url}/{user}")
            resp.raise_for_status()
        except (requests.ConnectionError):
            if i == allowed_retries:
                raise
        else:
            return resp.json() 

There must be better way 😇

  • Decorators 🎊
  • Context Managers 🌉
  • Common usecases already implemented 💪
In [7]:
from functools import wraps
def retry(exceptions, allowed_retries=5):
    def callable(func):
        @wraps(func)
        def wrapped(*args, **kwargs):
            for i in range(allowed_retries):
                try:
                    res = func()
                except exceptions:
                    continue
                else:
                    return res       
        return wrapped
    return callable
In [8]:
@retry(exceptions=requests.ConnectionError)
def get_relevant_restaurants(country):
    base_url = "cool_restaurants.com"
    resp = requests.get(f"{base_url}/{user}")
    resp.raise_for_status()
    return resp.json()
In [9]:
import tenacity

@tenacity.retry(retry=tenacity.retry_if_exception_type(ConnectionError))
def get_relevant_restaurants(user):
    base_url = "cool_restaurants.com"
    resp = requests.get(f"{base_url}/{user}")
    resp.raise_for_status()
    return resp.json()

Useful usecases 🧠

  • important note retry can be handled in the request itself by writing an adapter, but for the example sake i wont use it.

Lesson 8: Use patterns for better code reuse

Whats next ?! 🛸

Lets dive into exceptions types 🐠

  • Helps distinguish between different exceptions
  • Helps emphasis our intent
  • Builtin and Custom exceptions

When Builtin Exception Types ?🍅🍇🍆

  • Should default to use builtin exceptions
  • Familiar
  • Well documented, stackoverflow magic :)

When Custom Exception Types ? 🍅🍇🍆

  • Emphasis our intent
  • Distinguish between different exceptions.

Lets say we have ValueError and we want to recover in diffrent way between TooBig/TooSmall.

  • Group different exceptions.
  • Wrapping third party apis.
  • when we wrap third party api we minimize our dependecy on it. for example uppon recovery shouldn't have to import exceptions from your dependecies for example requests.exceptions
  • Also the users that use your library does not need/want to know about the implementation details.

Wrapping third party 👀

  • Minimize dependency
  • get_restaurant_recommendation can raise requests.ReadTimeout
  • Recovering in get_restaurant_recommendation
In [33]:
def login(user):
    pass
In [34]:
import requests

def get_restaurant_recommendation(path):
    # ...
    try:
        candidates = get_relevant_restaurants(user)
    except requests.exceptions.ReadTimeout:
        login.user()
    # ...

Exception cause 🤯

  • cause indicates the reason of the exception
  • We can overide the cause to replace the exception
In [35]:
try:
    1/0
except ZeroDivisionError:
    # Some amazing recovery mechanism     
    raise
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-35-21367911f8dd> in <module>
      1 try:
----> 2     1/0
      3 except ZeroDivisionError:
      4     raise

ZeroDivisionError: division by zero

Lesson 9: Pick the right exception types and messages.

Sensitive information 🕵

  • Exceptions will be spread far and wide 🇫🇷🇺🇸🇫🇷

through logging, reporting, and monitoring software.

  • Personal data 🕵

In a world where regulation around personal data is constantly getting stricter,

  • Never reveal your weaknesses, bad actors are everywhere 👺
  • You can never be too careful 🤓
In [ ]:
def login(user):
    raise CommonPasswordException(f"password: {password} is too common")

Lesson 10: Don’t use sensitive information in your exceptions.

Python hooks 🎣

  • Python has builtin hooks for various events
  • Doesn't require modifying existing code.

sys's excepthook example! 🎣

  • Uncaught exception print traceback to STDERR before closing
  • Unacceptable in production environment
  • Graceful exit by notify incident system
In [29]:
import sys
import rollbar

rollbar.init("Super Secret Token")

def rollbar_except_hook(exc_type, exc_value, traceback):
    rollbar.report_exc_info((exc_type, exc_value, traceback))
    sys.__excepthook__(exc_type, exc_value, traceback)
    
sys.excepthook = rollbar_except_hook

Useful usecases 🧠

  • Format Diffrently We can format the exceptions diffrently, to provide more/less information.
  • Redirect To Incident System We can redirect Exceptions to an incident system like rollbar or pager-duty.
  • Multi Threading Behaviour Since threading/multiprocessing have their own unhandled exception machinery. that is a bit customized so no unhandled exception exists at the top level. we might want to overide it to support KeyboardInterupt for example.
  • Search Stackoverflow 😛😛😛 Search Stackoverflow for the exception that was being raise

Lesson 14: Python has some useful builtin hooks

Common Gotchas 💀

Except block order ⚠

  • Except block order matter
  • Top to bottom
  • Specific exceptions first
In [1]:
try:
    raise ValueError
except Exception:
    result = "Exception"
except ValueError:
    result = "ValueError"
result
Out[1]:
'Exception'

NotImplemented vs NotImplementedError ⚠

In [17]:
raise NotImplementedError
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-17-91639a24e592> in <module>
----> 1 raise NotImplementedError

NotImplementedError: 
In [16]:
raise NotImplemented
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-16-2ae0db543f4a> in <module>
----> 1 raise NotImplemented

TypeError: exceptions must derive from BaseException

Return in finally block ⚠

In [21]:
def supprising_result():
    try:
        return "Expected"
    finally:
        return "Supprising"

supprising_result()
Out[21]:
'Supprising'

Lesson 11: Avoid exception handling gotchas.

This sounds like a lot of work 🏋

Not all programs made equal 👯

  • Extremely reliable ✈ ✨
  • Highly reliable 🚘
  • Reliable 💳
  • Dodgy 📱
  • Crap 💩

Lesson 1:* We want to build a fault tolerant to a certain degree

Still not perfect 💯

  • Hard to tell what exceptions can be thrown
  • Hard to tell where exceptions will be handled
  • No static analysis

Functional Exception Handling for the rescue 🚔

  • Use success/failure container values
  • functions are typed, and safe
  • Railway oriented programming

Lesson 17: Consider functional exception handling for complicated flows

Lessons: 👨‍🏫👩‍🏫

  • Lesson 1: We want to build a fault tolerant to a certain degree.
  • Lesson 2: Should catch relevant exceptions only.
  • Lesson 3: Different exceptions should be distinguishable.
  • Lesson 4: Use python syntax to the fullest.
  • Lesson 5: Error handling should not obscures business logic.
  • Lesson 6: Pick the right abstraction level for handling exceptions.
  • Lesson 7: validate, and fail fast !
  • Lesson 8: Use patterns for better code reuse
  • Lesson 9: Should pick the right exception types and messages.
  • Lesson 10: Don’t use sensitive information in exceptions.
  • Lesson 11: Avoid exception handling gotchas.
  • Lesson 12: Consider functional exception handling for complicated flows.
  • Lesson 13: Python has some useful builtin hooks.

Topics I didnt cover 🥵

Additional Resources 📚

Concurrent/Parallal Exception handling 🎸🎺🎻🎷

  • Multi-threading/processing 1, 2
  • Async 1,2, 3

Error codes 👾

When

  • WITHIN a program one should always use exceptions.
  • Any time the error must leave the program you are left with error error codes as exceptions can't propagate beyond a program.
  • If, however, I'm writing a piece of code which I must know the behaviour of in every possible situation, then I want error codes.
  • It's tedious and hard to write code that reacts appropriately to every situatio, but that's because writing error-free code is tedious and hard, not because you're passing error code

Pros

  • That being said, errors, whether in code form or simple error response, are a bit like getting a shot — unpleasant, but incredibly useful. Error codes are probably the most useful diagnostic element in the API space, and this is surprising, given how little attention we often pay them.

  • In general, the goal with error responses is to create a source of information to not only inform the user of a problem, but of the solution to that problem as well. Simply stating a problem does nothing to fix it – and the same is true of API failures.

Release it 📪

  • you can always reboot the world by restarding every single server layer by layer thats
  • almost always effective but takes long time
  • its like a doctor diagnosing desease, theyou could treat a patient,
  • counter integration point with circuit breaker and decoupling middleware
  • a cascading failure happens after something else already gone wrong. circuit breaker protect your system by avoiding calls out to the troubled integration point. using timeout ensure that you can come back from a call out to the troubled one

Recoverability 🩹

  • How do i recover

    • how can you make sure all bad state is cleared away to retry
  • what is recoverable:

    • network flakiness
    • database out of connection
    • disk unavailable
    • recoverable database out of connections

Bugs Aren’t Recoverable Errors! A critical distinction we made early on is the difference between recoverable errors and bugs:

A recoverable error is usually the result of programmatic data validation. Some code has examined the state of the world and deemed the situation unacceptable for progress. Maybe it’s some markup text being parsed, user input from a website, or a transient network connection failure. In these cases, programs are expected to recover. The developer who wrote this code must think about what to do in the event of failure because it will happen in well-constructed programs no matter what you do. The response might be to communicate the situation to an end-user, retry, or abandon the operation entirely, however it is a predictable and, frequently, planned situation, despite being called an “error.”

A bug is a kind of error the programmer didn’t expect. Inputs weren’t validated correctly, logic was written wrong, or any host of problems have arisen. Such problems often aren’t even detected promptly; it takes a while until “secondary effects” are observed indirectly, at which point significant damage to the program’s state might have occurred. Because the developer didn’t expect this to happen, all bets are off. All data structures reachable by this code are now suspect. And because these problems aren’t necessarily detected promptly, in fact, a whole lot more is suspect. Depending on the isolation guarantees of your language, perhaps the entire process is tainted.

Reasons for errors

  • The obious one is that something exceptional happened.
  • As a control flow mechanism.
  • Can be triggered due to a bug in our code.

Types of errors

  • error that can be detected at compile time
  • errors that can be deteled at run time
  • errors that can be infered
  • reproducieable erros
  • non reproduceable errors

Types of exception handling

  • EAFP (it’s easier to ask for forgiveness than permission)
  • LBYL (Look before you leap)
  • Each has its own pros and cons (whether the thread-safty or readability)
  • but both are legitimate in python as oppose to other languages.