Write more useful exceptions in python

Why I’m writing this

I’ve recently (not so recent now: this is an old post I rescued from my drafts folder) been tidying up error reporting at work. There’s a few noisy exceptions coming from some third party libraries and older code. I’d like to be able to filter them out programmatically so that they don’t end up in sentry.

However, I’ve been running into a problems when the exceptions are raised like this:

raise Exception("The thing timed out for reason X")

I’m having to do a combination of rewriting code and some fragile string matching on the error messages so that I can filter out what’s really an important error, what can be retried and what is more like a warning.

What would I prefer

For anyone writing libraries, either publicly or for use within a codebase, there’s a number of steps that can be applied to make things better.

It’s also worth thinking about redesigning and using exceptions less, especially for flow control, but that’s a bigger change and a post topic for another day.

Pick a good base exception from the standard library

The python standard library defines a number of exception classes. Starting with the most specific one you can find communicates a lot about what kind of error has happened.

Sub typed errors

Following on from picking a good base exception. Create your own class for each type of error you have and give it a sensible name. For example there are lots of different things that can timeout. Rather than throwing a generic TimeoutError exception your library could subclass it:

class QueryTookTooLong(TimeoutError):
    pass

This makes it easier to catch and allows consumers to handle different errors in a different way:

try:
    do_something()
except QueryTookTooLong:
    record_slow_query()
except ServerRejectedQueryBecauseItWasBusy:
    back_off_and_try_a_different_server()
except Exception:
    panic_an_error_we_didnt_know_about_happened()
class SuperCoolPackageError(Exception, ABC):
    """All exceptions are instances of a SuperCoolPackageError"""
    pass

class QueryTookTooLong(TimeoutError, SuperCoolPackageError):
    pass

This means consumers can handle all errors from your package in a consistent way. For example error stats for your library could be fed into monitoring or logging and help the team asses if more work needs to be done on the integration of the library.

Provide structured data in the exception

class QueryTookTooLong(TimeoutError, SuperCoolPackageError):
    server_address: str
    query_type: str

This makes it easier to programmatically act on the exception. Useful information can also be extracted into a logging context. Maybe there’s a particular server that’s having a problem?

Add notes and explanations to the exception’s docstring

This is often the first place a user will land when trying to debug your exception. The more context and details you have here the easier it is for your consumers to understand what happened.

SqlAlchemy has a number of good examples of this:

# Code sample from https://github.com/sqlalchemy/sqlalchemy/blob/d5e31d130808c94f09e51e9afb222c4efa63875c/lib/sqlalchemy/orm/exc.py#L35
class StaleDataError(sa_exc.SQLAlchemyError):
    """An operation encountered database state that is unaccounted for.
    Conditions which cause this to happen include:
    * A flush may have attempted to update or delete rows
      and an unexpected number of rows were matched during
      the UPDATE or DELETE statement.   Note that when
      version_id_col is used, rows in UPDATE or DELETE statements
      are also matched against the current known version
      identifier.
    ...
    """

If you have documentation about the error on the web. Link to it. Again, the SqlAlchemy library deserves a mention. It provides links to documentation on the web for each exception. This really helps with the developer experience and makes your library much easier to use.

# Code sample from https://github.com/sqlalchemy/sqlalchemy/blob/79dbe94bb4ccd75888d57f388195a3ba4fa6117e/lib/sqlalchemy/exc.py#L54
def _code_str(self) -> str:
    return (
        "(Background on this error at: "
        "https://sqlalche.me/e/%s/%s)"
        % (
            _version_token,
            self.code,
        )
    )

Thank you

Hopefully at least one of these suggestions (they can all be applied independently) is useful to you and you’ll take it away and improve some code you’re working on.

Discuss this post over on reddit