Understanding Best Practices for propagating different errors in libraries

I'm building a small internal library that takes CSV data and writes it into a xlsx workbook, and I wrote a function called read_csv_into_excel.

The function can error in several ways including:

  • io error if the file can't be found/read
  • csv error if there's a csv parsing problem
  • parse error if the data in the csv can't be converted to f32
  • xlsx error if I can't write to the worksheet

My initial thought was to use anyhow, but as I understand that's not considered best practice for a library... so I thought I'd use thiserror and make a simple error-holder:

#[derive(Error, Debug)]
pub enum ReportError{
    IoError(#[from] io::Error),
    CsvError(#[from] csv::Error),
    ParseError(#[from] ParseFloatError),
    XlsxWriterError(#[from] XlsxError)
}

But this won't build, complaining the error doesn't implement display... So I rewrote it as:

#[derive(Error, Debug)]
pub enum ReportGenError {
    #[error("{0}")]
    IoError(#[from] io::Error),
    #[error("{0}")]
    CsvError(#[from] csv::Error),
    #[error("{0}")]
    ParseError(#[from] ParseFloatError),
    #[error("{0}")]
    XlsxWriterError(#[from] XlsxError)
}

Which compiles and runs... but I'm wondering if this is the correct approach. If it's possible to have these disconnected types of errors, and I'm just forwarding their display data anyway... would I be better off shoving them into a Box<dyn Error> and just returning that?

Are there any pros/cons to each? What do most libraries in the rust ecosystem do for this?

Your question isn't necessarily thiserror specific. thiserror is primarily a tool to cut down on the boilerplate. You can do what it does yourself.

For a surface level suggestion, given the code you've shared -- does each error only happen based on a particular operation? You could at least add minimal context if so.

pub enum ReportGenError {
    #[error("could not read input file: {0}")]
    IoError(#[from] io::Error),
    #[error("invalid input file: {0}")]
    CsvError(#[from] csv::Error),
    #[error("{0}")]
    ParseError(#[from] ParseFloatError),
    #[error("could not write output file: {0}")]
    XlsxWriterError(#[from] XlsxError)
}

In my honest opinion though, the real answer comes down to how much effort you're willing to put in to get great errors, and how much you care about backwards compatibility given that this is an internal library. If you don't care about backwards compatibility at all, you can start with your current enum (or even Box<dyn Error>) and freely iterate over time to improve your errors.

And in my experience, you're probably are going to want to improve the errors eventually, even if this is for personal use. What file had an error, what line had an error... being told things like that are often significant QOL improvements.

But I don't think there's an easy path to having great errors. For great errors you need different error types for different operations, and to add context at each error site (like file names and line numbers), and also for nested errors (backtrace-like functionality). It takes a lot more thought up front, especially if you can't iterate freely.

Here's my favorite article about writing a library with great errors,[1] if you're willing to put in the effort.

My guess is "one big enum for all errors" is the most common approach for libraries. (The article I linked explores where that falls short.)


  1. There are alternatives to IIFEs for constructing errors. ↩ī¸Ž

6 Likes

Here's some food for thought regarding error handling in libraries: Modular Errors in Rust - Sabrina Jewson.

1 Like

That is a really good, detailed answer. Thank you. The linked article was also great.

It looks like a better, long term approach may be adding some context:

#[derive(Error, Debug)]
pub enum ReportGenError {
    #[error("Failed to read file {filename}: {source}")]
    FileReadError{
        filename: PathBuf,
        source: io::Error
    },

    #[error("Failed to process CSV: {0}")]
    CsvError(#[from] csv::Error),

    #[error("failed to parse csv at line {line}: {source}")]
    CsvParseError{
        line: u32,
        source: ParseFloatError
    },

    #[error("Error processing Excel worksheet: {0}")]
    XlsxWriterError(#[from] XlsxError)
}

And then using map_err to add context as needed when something goes wrong.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.