Kimani's Ramblings

On the Structuring of Errors

A while back, I spiked an effort to eliminate the crate anyhow from several of my company's projects.

This is not because anyhow is a bad crate. In fact, I think it is quite a good crate - it achieves what it sets out to do effectively and has many reasonable use scenarios.

In fact, we brought it into our project initially for those reasons - it let us quickly spin up new code without spending a ton of time thinking about error types.

However, over time, we found that it kept trying to creep into our core business logic. I don't blame anyone for this. When you're under pressure to deliver software, it's very tempting to reach for a tool which saves you some effort, and being able coerce any error into a single return type is pretty handy.

So why is it a problem that it was getting into our core logic?

Let's step back for a moment and talk about handling exceptions.

Exceptions are Bugs.

I'll leave convincing you that why exceptions are bugs to others.

But if you believe that exceptions might be bugs, then we're ready to talk about why anyhow was becoming a problem.

"Now hold on, Kimani! Rust doesn't have exceptions!" Indeed. But bear with me for a moment.

One of the fundamental reasons for believing that exceptions are bugs has to do with the erasure of information.

By throwing an exception, you erase context - for both the compiler as well as the callers of your code.

When you cast an error to anyhow::Error and return it1, you erase context - for the compiler and the callers of your code.

Erased Errors are Bugs

anyhow was encouraging us to erase type information, making functions opaque and difficult to predict. Certainly, an opaque anyhow::Result is still better than a runtime exception since you at least know to check for errors, but your ability to handle those errors is significantly diminished.

Let us consider some example Rust.

pub fn do_some_work(input: InputType) -> anyhow::Result<()> {
    check_input(&input)?;
    let result = process_input(input)?;
    send_result(result)?;
    Ok(())
}

And we'll also define some signatures:

pub fn check_input(input: &InputType) -> Result<(), InputError>;
pub fn process_input(input: InputType) -> Result<ResultType, ProcessingError>;
pub fn send_result(result: ResultType) -> Result<(), SendError>;

Now, anyhow sure made writing do_some_work convenient! Despite the three functions having disparate error types, we can just use ? and move on our way!

But what's the experience of someone calling do_some_work elsewhere in the application?

fn some_other_function() {
    let input = ...;
    match do_some_work(input) {
        Ok(_) => {
            log::debug!("It worked!")
        }
        Err(e) => {
            // Well, now what?
        }
    }
}

If we land in the error case, we only have e, which has been type-erased to an anyhow::Error. We can't match on it. We can't meaningfully determine what sort of error occurred.

The answer to this problem is structured errors.

Let's revise our code with the help of another error-handling crate, thiserror.

#[derive(thiserror::Error)]
pub enum WorkError {
    #[error("Input is not valid")]
    InvalidInput {
        #[from]
        cause: InputError,
    },
    #[error("Processing error")]
    Processing {
        #[from]
        cause: ProcessingError,
    },
    #[error("Failed to send result")]
    Send {
        #[from]
        cause: SendError,
    },
}

pub fn do_some_work(input: InputType) -> Result<(), WorkError> {
    check_input(&input)?;
    let result = process_input(input)?;
    send_result(result)?;
    Ok(())
}

Note that the definition of do_some_work didn't change, only the signature! We had to write some additional boilerplate, but I find this experience to be relatively painless with the help of thiserror and a competent IDE.

The payoff is for the caller:

fn some_other_function() {
    let input = InputType {};
    match do_some_work(input) {
        Ok(_) => {
            log::debug!("It worked!")
        }
        Err(e) => {
            match e {
                WorkError::InvalidInput { .. } => {}
                WorkError::Processing { .. } => {}
                WorkError::Send { .. } => {}
            }
        }
    }
}

They can clearly see what failed and handle each case appropriately!

It is considered common wisdom in Rust spaces that "anyhow is for applications, thiserror is for libraries". I contend that this is somewhat misleading.

anyhow is great for writing top-level functions - things where any error is de-facto "fatal", and it's not important to preserve context. But even in an application, maintaining structured errors in business logic is important for appropriate error handling.

Writing the structured errors for return types can feel like additional overhead, but I actually find that having to enumerate the ways in which something can fail to be a useful exercise, and the payoff to be worthwhile. Ultimately, with some help from thiserror's attributes, you can even achieve the same simple ? style as you would with anyhow.

Which brings us back to me removing anyhow. We were finding that writing error handling in our code was difficult in places where anyhow was involved, so I decided to just bite the bullet and remove it. The lift to model errors didn't up being too bad, and several months on, it hasn't really proved to be any impediment to work on those projects. Going forward, I'll certainly keep in mind that the allure of easy error casting brings us to the same sort of issues that exceptions do in the long run.

This blog is Rust-focused, but I think I have a fairly language-agnostic takeaway: that while an Ok/Error binary return type is better than runtime exceptions, structured errors are miles better still.

  1. You don't need anyhow for this, incidentally. You can do the same thing with Box<dyn Error>.

#rust #software