TIFIIT: Exceptions vs Results

4 min read Original article ↗

I fell in love with the Result type in Rust immediately when using it. For most of my career I’ve used Python, and one part of the language that is contentious is Exceptions. One camp, generally the senior engineers, think that Exceptions should be avoided and you shouldn’t raise custom Exceptions. The other camp thinks that Exceptions are a part of Python and can be used powerfully.

What we run into early in our career as junior engineers moving into senior engineers, is that if you rely too heavily on Exceptions your code may become unclear. And it may be unclear what Exceptions could be raised for a given function. This results (no pun intended) in senior engineers avoiding Exceptions.

This past week I had a coding interview; part of this required working with an external 3rd party API. This API could return many different errors (like in real life) and depending on the error, we might want to do something different. My implementation used Exceptions and was quite elegant. However, it got me thinking about why it was elegant and took me back to Rust’s Result type.

import requests

def brute_force_retry(url: str) -> requests.Response:
    response = requests.get(url)

    while not response.ok:
        if response.status_code == 401:
            raise AuthRequired()
        elif response.status_code == 404:
            raise SkipEndpoint()
        
        # otherwise, retry the request
        response = requests.get(url)
    
    # return the successful request
    return response

We wanted to retry fetching a URL in the case of 503s but for 404s we wanted to just skip this endpoint altogether and 401s required some extra work. Instead of raising custom Exceptions, I could have returned None or a custom sentinel object like AUTH_REQUIRED or SKIP_ENDPOINT. Let’s look at my error handling code.

urls = deque(["starting-url"])

while urls:
    next_url = urls.popleft()
    
    try:
        response = brute_force_retry(next_url)
    except AuthRequired:
        urls.append(next_url)
        continue
    except SkipEndpoint:
        continue

    # handle response ...

We were crawling URLs based on the response. Some of the URLs required an authentication header which was a response from a subsequent URL. When I looked at this code, it reminded me a lot of how Rust might handle different errors with matching.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file_result = File::open("non_existent_file.txt");

    match file_result {
        Ok(file) => {
            println!("File opened successfully: {:?}", file);
        }
        Err(error) => match error.kind() {
            ErrorKind::NotFound => {
                println!("Error: File not found. Creating a new one...");
                match File::create("non_existent_file.txt") {
                    Ok(new_file) => println!("New file created: {:?}", new_file),
                    Err(create_error) => eprintln!("Error creating file: {:?}", create_error),
                }
            }
            other_error => {
                eprintln!("Problem opening the file: {:?}", other_error);
            }
        },
    }
}

In Rust, the error handling is explicit and there are no Exceptions that can be raised (panic is unique but still not an Exception).

I love the explicitness of Rust’s error handling, but I do find myself still wanting to just raise an Exception like in Python. There is a way to implicitly return an error by using ? but that’s out of scope for this blog post.

Five years ago, I experimented adding results to Go, but it was ugly given no generics at the time. This week, I experimented adding them to Python. It works with Python’s new match syntax and is quite nice. It will only work on your custom exceptions that inherit from resulty.ResultyException but could be extended to handle all exceptions.

import requests
import resulty


@resulty.resulty
def brute_force_retry(url: str) -> requests.Response:
    response = requests.get(url)
    response.raise_for_status()

    while not response.ok:
        if response.status_code == 401:
            raise AuthRequired()
        elif response.status_code == 404:
            raise SkipEndpoint()
        
        # otherwise, retry the request
        response = requests.get(url)
    
    # return the successful request
    return response


urls = deque(["starting-url"])

while urls:
    next_url = urls.popleft()

    match brute_force_retry(next_url):
        case resulty.Ok(response):
            ...
        case resulty.Err(exc):
            match exc:
                case AuthRequired():
                    urls.append(next_url)
                case SkipEndpoint():
                    pass

    # handle response ...

I think this is much worse than our simple Exception handling before with the try/except! It’s cool that we can do this in Python, but it’s totally un-Pythonic. As we nest match/case statements, this implementation becomes unreadable.

My conclusion is this: use your language’s features as they were intended. In Rust, we have to use Results. In Python, we can use a Result-like syntax, but we’re better off using Exceptions. To those saying Exceptions shouldn’t be used for control-flow, I say that’s why Python gave us Exceptions.


Github Repo: https://github.com/tizz98/resulty

Photo by Christina Morillo: https://www.pexels.com/photo/woman-programming-on-a-notebook-1181359/