1 unstable release

new 0.1.0 Dec 21, 2024

#878 in Rust patterns

MIT/Apache

46KB
853 lines

A library for error handling and reporting.

Disclaimer: This library is in an exploratory stage of development and should be considered experimental.

Reportify helps you create rich, user-friendly error reports that clarify issues and provide valuable feedback to users.

Here's an example of an error report generated with Reportify for a missing configuration file:

Preamble

Let's first clarify what we mean by error handling and error reporting, respectively. While these terms may be used differently by different people, we are going to use the following characterizations for the purpose of this documentation:

  • Error handling concerns the ability of a program to internally deal with errors.
  • Error reporting concerns the ability of a program to present errors to humans.

For instance, when trying to read a configuration file that is missing, a program may either handle the error internally by falling back to a default configuration or report the error to the user informing them about the missing file. Note that the recipient of an error report may also be a developer or a member of a support team helping a user troubleshoot a problem.

While being related, error handling and reporting are different concerns and should be treated as such. Error handling requires well-structured, explicitly-typed errors that can be programmatically inspected. In contrast, error reporting often benefits from freeform information, such as suggestions, further explanations, or backtraces, that is not required for error handling but essential for good error reports, i.e., error reports that are easy to understand and helpful for troubleshooting problems.

Effective error handling and reporting is crucial for applications to be robust and user-friendly.

Rationale and Use Case

Much has been said about error handling and reporting in Rust and, as a result, the Rust ecosystem already provides a myriad of error-related libraries for diverse use cases. So, why on earth do we need yet another such library?

Let's first look at two of the most popular libraries for error handling and reporting, Thiserror and Anyhow. While being written by the same author, David Tolnay, they are very different. Thiserror provides a derive macro for conveniently defining specific error types, e.g., enumerations of all possible errors that may occur in a given context. In contrast, Anyhow provides a single type-erased error type making it easy to propagate arbitrary errors up to the user for reporting while adding helpful, freeform information along the way. In general, specific error types have the following advantages over a single type-erased error type:

  • They are required to handle errors selectively without down-casting.
  • They force a considerate and explicit propagation of errors.
  • They are open to extension while maintaining backwards compatibility.

With a single type-erased error type, we seemingly trade those advantages for the following conveniences:

  • Straightforward propagation of arbitrary errors regardless of their type.
  • Ad-hoc creation of one-of-a-kind errors.
  • Ability to add freeform context information to errors.

Due to these characteristics, Thiserror is typically used by libraries to define specific error types enabling selective handling of errors by callers whereas Anyhow is typically used by applications where a vast array of errors may occur and most of them will simply be propagated and eventually be reported to a user. In Error Handling In Rust - A Deep Dive, Luca Palmieri argues that the choice between Thiserror and Anyhow depends on whether you expect that a caller might need to handle different kinds of errors differently. With the earlier introduced distinction between error handling and reporting, Thiserror is for errors that might need to be selectively handled while Anyhow is for errors that will eventually be reported. When using Anyhow you deprive callers of the ability to handle errors selectively and when using Thiserror you do not get the conveniences of Anyhow.

Now, as usual in the Rust world, our goal is to have our cake and eat it too.^1 So, can we have some of the conveniences of a single type-erased error type while also retaining the advantages of specific error types? This library explores a design around specific error types that strives to carry over the conveniences of a single type-erased error type in a way that gives developers the choice to opt-in into specific conveniences, such as straightforward propagation of errors of arbitrary types.

The intended use case of this library are applications written in library-style, where application logic is implemented as a reusable library and the executable is a thin wrapper around it. Take Cargo as an example of such an application. Cargo is written around a Cargo library which can be used independently of the Cargo application. Cargo, like many other Rust applications, uses Anyhow, including in their library. In case of applications written in library-style, most of the errors will be reported, one-of-a-kind errors are common, and we would like the ability to add freeform information to errors and often report errors of arbitrary types. That's why using Anyhow makes sense for this use case. However, Anyhow is still not ideal for the following reasons:

  • A single type-erased error type makes it far too easy to implicitly---using the ? operator---propagate any errors without any consideration of whether context should be added at a given point or whether the error could be handled. Without a huge amount of programming discipline this can quickly lead to hard to understand error reports.[^2]
  • One does not always know or want to decide at a given point whether an error might be selectively handled down the callstack or be eventually reported. In such cases, it makes sense to return an error that has a specific type and can be handled while also having the ability to add freeform information should the error be eventually reported.
  • As an application evolves one may need to add the ability to selectively handle specific errors to certain parts of the application. If Anyhow is used pervasively throughout the entire codebase this may require a huge refactoring effort because there are no specific error types which could be extended in a backwards-compatible way.

The design of this library aims to address these shortcomings by promoting the usage of specific error types in applications. It does so by providing functionality that makes it more convenient to deal with them.

Errorstack is another, more mature library in the Rust ecosystem that has a philosophy similar to this library. If you find the above considerations convincing, you should check it out. In fact, Errorstack was a huge inspiration for this library. While similar in philosophy, this library explores a different API with the explicit aim to reduce a bit of the friction introduced by Errorstack's design while still keeping enough friction to force developers to be considerate when it comes to error propagation.[^3]

Errors and Reports

This library is build around the type Report<E> where E is a specific error type for error handling and Report augments E with additional freeform information for error reporting, thereby cleanly separating both concerns.

The functionality provided by this library serves two main purposes, report creation and report propagation. Report creation is about creating a Report<E> based on some other value, e.g., a plain error of type E. Report propagation is about taking a Report<E> and turning it into a Report<F> when crossing an error boundary beyond which error handling requires a different type, e.g., when multiple different types of errors are possible and they need to be combined, or when one wants to abstract over the details of some error type. Report creation and propagation are almost always explicit.

The extension traits ErrorExt and ResultExt, enable the convenient creation and propagation of reports:

Whatever Trait

Errors that can be created from arbitrary other errors or be constructed from strings as one-of-a-kind errors must implement the trait Whatever. Implementing this trait makes it convenient to construct reports of these errors in various ways.

use reportify::{bail, Report, ResultExt};

// Define a simple `Whatever` error type.
reportify::new_whatever_type! {
    /// Application error.
    AppError
}

// Report creation from arbitrary errors using `.whatever`.
fn read_file(path: &Path) -> Result<String, Report<AppError>> {
    std::fs::read_to_string(path)
        .whatever("unable to read file to string")
        .with_info(|_| format!("path: {path:?}"))
}

// Report creation for one-of-a-kind errors using `bail!`.
fn do_something() -> Result<(), Report<AppError>> {
    bail!("unable to do something")
}

By implementing Whatever one opts-in into the usual conveniences of Anyhow. Note that these errors cannot easily be handled selectively, however, should that need arise in the future, they can be extended in a backwards-compatible way:

use reportify::{bail, ErrorExt, Report, ResultExt};
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("invalid configuration")]
    InvalidConfig,
    #[error("other error")]
    Other,
}

impl reportify::Whatever for AppError {
    fn new() -> Self {
        Self::Other
    }
}

// Report creation from arbitrary errors using `.whatever`.
fn read_file(path: &Path) -> Result<String, Report<AppError>> {
    std::fs::read_to_string(path)
        .whatever("unable to read file to string")
        .with_info(|_| format!("path: {path:?}"))
}

// Report creation for one-of-a-kind errors using `bail!`.
fn do_something() -> Result<(), Report<AppError>> {
    bail!("unable to do something")
}

// Function exploiting the specific error type.
fn load_config() -> Result<(), Report<AppError>> {
    Err(AppError::InvalidConfig.report())
}

Calling whatever on an error or result will create a report using the Whatever implementation of the error type. The respective methods always require a description of the error thereby forcing developers to describe the error.

[^2]: The creators of Errorstack found that adding friction to error propagation forces developers to be more considerate and provide context, resulting in improved error reports (see https://hash.dev/blog/announcing-error-stack#what's-the-catch). [^3]: Recall that this library is still in an exploratory stage, so this might not actually work out.

Dependencies

~4–11MB
~128K SLoC