Python errors as values: Comparing useful patterns from Go and Rust
Aaron Harper · 11/8/2023 · 6 min read
Errors happen in programs -- they're unavoidable! It's important to know both where errors can happen and how to effectively handle them. As we developed our Python SDK, safe and effective error handling was paramount.
In this post we'll:
- Compare the two primary ways of handling errors: thrown errors and errors as values.
- Demonstrate how to handle errors as values in Python (a traditionally thrown-error language).
Thrown errors
A thrown error interrupts the control flow, propagating down the call stack until it's caught. If it isn't caught then the program cashes. A big problem with this approach is a lack of clarity on where an error might occur. For example, which lines in the following code throw an error?
def upsert_thing(thing_id: str) -> Thing:thing = get_thing(thing_id)thing.set_name("Doodad")update_thing(thing)log_thing(thing)return thing
It's impossible to know which line might throw an error without reading the functions themselves... and the functions those functions call... and the functions those functions call. Some thorough engineers may document thrown errors but documentation is untested and therefore untrustworthy. Java is a little better because it forces you to declare uncaught errors in method signatures.
So if we want to be really safe then we'll wrap each call with a try/catch
:
def upsert_thing(thing_id: str) -> Thing:try:thing = get_thing(thing_id)except Exception as err:# Swallow error and create a new Thingthing = Thing(thing_id)try:thing.set_name("Doodad")except Exception as err:raise Exception(f"failed to set name: {err}") from errtry:update_thing(thing)except Exception as err:raise Exception(f"failed to update: {err}") from errtry:log_thing(thing)except Exception as err:# Swallow error because logging isn't criticalpassreturn user
As we think about each possible error we realize that our original logic would crash the program when we didn't want it to! But while this is safe it's also extremely verbose. Python engineers overwhelmingly agree, which is why most Python code has 1 large try/catch
at best:
def upsert_thing(thing_id: str) -> Thing:try:thing = get_thing(thing_id)thing.set_name("Doodad")update_thing(thing)log_thing(thing)except Exception as err:raise Exception(f"something errored ¯\_(ツ)_/¯: {err}")return thing
So the thrown error approach:
- Doesn't tell us which functions could error.
- Doesn't force us to handle errors where they happen.
- Encourages engineers to use coarse-grained error handling (i.e. one big
try/catch
).
There has to be a better way 🤔.
Errors as values
Some languages (e.g. Go and Rust) take a different approach: they return errors rather than throwing them. By returning errors, these languages force engineers to notice, think about, and handle errors.
Go returns errors using a tuple (well, not really a tuple but it looks like one!), putting the error last by convention:
// Define a function that returns a User or errorfunc getUser(userID string) (*User, error) {rows := users.Find(userID)if len(rows) == 0 {return nil, errors.New("user not found")}return rows[0], nil}func renameUser(userID string, name string) (*User, error) {// Consume the functionuser, err := getUser(userID)if err != nil {return nil, err}user.Name = namereturn user, nil}
Rust returns returns errors using a "wrapper" type called Result
. A Result
contains both a non-error value (Ok
) and an error value (Err
):
// Define a function that returns a Result with a User or an error stringfn get_user(user_id: &str) -> Result<Option<User>, &str> {match find_user_by_id(user_id) {Some(user) => Ok(Some(user)),None => Err("user not found"),}}fn rename_user(user_id: &str, name: String) -> Result<User, &str> {// Consume the functionmatch get_user(user_id) {Ok(Some(mut user)) => {user.name = name;Ok(user)},Ok(None) => Err("user not found"),Err(e) => Err(e),}}
Regardless of the specific approach, returning errors as values makes us consider all of the places an error could occur. The error scenarios become self-documenting and more thoroughly handled.
Errors as values in Python
So how can we treat errors as values in Python? We could take the Go approach and return a tuple:
# Define a function that returns a tuple of a User and an errordef get_user(user_id: str) -> tuple[User | None, Exception | None]:rows = users.find(user_id=user_id)if len(rows) == 0:return None, Exception("user not found")return rows[0], Nonedef rename_user(user_id: str, name: str) -> tuple[User | None, Exception | None]:# Consume the functionuser, err = get_user(user_id)if err is not None:return None, err# Unnecessary check but the type checker can't know thatassert user is not Noneuser.name = namereturn user, None
Since the type checker doesn't know the tuple values are mutually exclusive, we're forced to do a superfluous assert user is not None
. Otherwise, the type checker incorrectly thinks user
is nullable.
Next, let's try something Rust-like using the awesome library result, which leverages pattern matching:
import result# Define a function that returns a Resultdef get_user(user_id: str) -> result.Result[User, Exception]:rows = users.find(user_id=user_id)if len(rows) == 0:return result.Error(Exception("user not found"))return result.Ok(rows[0])def rename_user(user_id: str, name: str) -> result.Result[User, Exception]:# Consume the functionmatch get_user(user_id):case result.Ok(user):passcase result.Err(err):return result.Err(err)user.name = namereturn result.Ok(user)
Better than the tuple! But we still have some downsides:
- Verbose.
- Language servers don't understand that a
return
in both cases will always end the function. In other words, they don't know that checking bothresult.Ok
andresult.Err
is exhaustive. - Pattern matching is new in Python (
3.10
). Many people are on older versions and many others are hesitant to introduce thematch
statement into their codebase. - External dependency (the
result
package).
The last approach we'll try is returning a union:
# Define a function that returns a union of a User and an errordef get_user(user_id: str) -> User | Exception:rows = users.find(user_id=user_id)if len(rows) == 0:return Exception("user not found")return rows[0]def rename_user(user_id: str, name: str) -> User | Exception:# Consume the functionuser = get_user(user_id)if isinstance(user, Exception):return useruser.name = namereturn user
This looks great! We didn't need superfluous assertions (like the tuple approach) and we didn't introduce new patterns (like the result
approach). Unions work because isinstance
supports type narrowing:
- Within the
if isinstance(user, Exception)
block, theuser
variable is narrowed fromUser | Exception
toException
. - Since we set
user = User()
whenuser
is anException
, type checkers will understand thatuser
cannot beException
after theif
statement.
Conclusion
Inngest's Python SDK handles errors as values, since this integrates error handling into the normal control flow of the program. This makes programs more verbose, but it ensures that we're properly handling errors.
We implemented errors as values using unions, since that was the most idiomatic and terse approach. Other languages use tuples (e.g. Go) or wrapper types (e.g. Rust), but we felt that these patterns either didn't work well in Python, were too verbose, or heavily used a pattern that isn't yet idiomatic.