SNAFU 0.2.1 Released

#1

TL;DR: SNAFU creates easy-to-use errors out of Rust enums using a procedural macro and extension traits.

Example

Here’s the highlight of what it provides:

#[derive(Debug, Snafu)]
enum Error {
    #[snafu(display("Could not validate password: {} (connected to {})", source, address))]
    ValidatePassword {  address: String, source: auth_server::Error }.
}

// ...

auth_server::validate(address, username, password)
    .context(ValidatePassword { address })?;

Here’s a complete working example with comments about the features and a few more bells-and-whistles:

mod my_library {
    use snafu::{ResultExt, Snafu};

    // This is an error for a public API; nothing about the details
    // is exposed, just that error-related traits are implemented.
    #[derive(Debug, Snafu)]
    pub struct Error(InnerError);

    // This is an error for an internal API; all the details are
    // available for methods and tests.
    #[derive(Debug, Snafu)]
    enum InnerError {
        // Attributes allow us to write our `Display` implementation
        // concisely.
        #[snafu(display("Could not validate password: {} (connected to {})", source, address))]
        ValidatePassword {
            address: String,
            // We know that this is another SNAFU error, so we
            // delegate our backtrace information to it
            #[snafu(backtrace(delegate))]
            source: auth_server::Error,
        },

        #[snafu(display("Could not load user from database: {}", source))]
        LoadUser { source: database::Error },
        
        #[snafu(display("User ID in database was invalid: {}", source))]
        InvalidUserId { source: std::num::ParseIntError },
    }

    // This type alias makes using our error type easy, but also
    // allows for one-off funnctions with a different error type.
    type Result<T, E = InnerError> = std::result::Result<T, E>;

    #[derive(Debug)]
    pub struct User {
        id: i32,
    }

    // This is a public function so it uses the public error type
    pub fn login(username: &str, password: &str) -> Result<User, Error> {
        check_password_matches(username, password)?;
        let user = load_and_convert_from_db(username)?;
        Ok(user)
    }

    const AUTH_SERVER_ADDR: &'static str = "127.0.0.1";

    fn check_password_matches(username: &str, password: &str) -> Result<bool> {
        let address = AUTH_SERVER_ADDR;

        // We can provide additional data when we encapsulate a
        // lower-level error, such as the address of the server.
        let valid = auth_server::validate(address, username, password)
            .context(ValidatePassword { address })?;

        if !valid {
            eprintln!("Bad password attempt for '{}'", username);
        }

        Ok(valid)
    }

    fn load_and_convert_from_db(username: &str) -> Result<User> {
        let db_user = database::get_user(username).context(LoadUser)?;

        let id = db_user.id.parse().context(InvalidUserId)?;

        Ok(User { id })
    }

    mod auth_server {
        use snafu::Snafu;

        #[derive(Debug, Snafu)]
        pub enum Error {
            #[snafu(display("This is just a dummy error"))]
            Dummy { backtrace: snafu::Backtrace },
        }

        pub fn validate(_address: &str, _username: &str, _password: &str) -> Result<bool, Error> {
            Ok(true)
        }
    }

    mod database {
        pub struct User {
            pub id: String, // We have a really bad DB schema...
        }

        // SNAFU interoperates with any type that implements `Error`
        pub type Error = Box<dyn std::error::Error>;

        pub fn get_user(_username: &str) -> Result<User, Error> {
            Ok(User { id: "42".into() })
        }
    }
}

// There are extension traits for `Result` and `Option`
use snafu::{OptionExt, ResultExt, Snafu};
use std::io::{self, BufRead};

// SNAFU works equally well for libraries and applications
#[derive(Debug, Snafu)]
enum Error {
    #[snafu(display("Could not log in user {}: {}", username, source))]
    LoginFailure {
        username: String,
        source: my_library::Error,
    },

    #[snafu(display("A username is required"))]
    UsernameMissing,

    #[snafu(display("The username was malformed"))]
    UsernameMalformed { source: io::Error },

    #[snafu(display("A password is required"))]
    PasswordMissing,

    #[snafu(display("The password was malformed"))]
    PasswordMalformed { source: io::Error },
}

fn main() -> Result<(), Error> {
    let stdin = std::io::stdin();
    let stdin = stdin.lock();
    let mut stdin = stdin.lines().fuse();

    let username = stdin
        .next()
        .context(UsernameMissing)?
        .context(UsernameMalformed)?;
    let password = stdin
        .next()
        .context(PasswordMissing)?
        .context(PasswordMalformed)?;

    let user = my_library::login(&username, &password).context(LoginFailure { username })?;
    println!("Got user: {:?}", user);

    Ok(())
}

How it works

The macro creates a unique type corresponding to each enum variant; I call these context selectors. These context selectors are used with the context method to easily wrap underlying errors along with additional data into a custom error type. Attributes allow for customization of aspects like the Display implementation.

4 Likes
#2

I’ve had my head in this code for a while, so all of the benefits seem obvious to me, but I know that they aren’t. I’m really looking forward to some constructive feedback about how I can improve the crate to better showcase what it provides.

I’ve talked to a few people who are weirded out by the fact that a procedural macro creates additional types. I know that will be a deal-breaker for some, but I hope that the twin facts of:

  • the context selectors are internal to the implementing crate
  • the context selectors are not hidden details but instead are in-your-face

means that people will be willing to give it a shot!

2 Likes
#3

Looks great, I’ll check it out for sure!

Question about your design philosophy on error type: Modules form a tree, and the errors in a module closer to the root are often the union of the error from the child modules, plus something extra. E.g. you have some code like

fn in_parent_mod(...) -> Result<X, E> {
    fn_in_child1()?;
    fn_in_child2()?;
    some_io_stuff()?;
    Ok(...)
}

Never mind the details, but what’s E supposed to be here? The way I see it, there could be a crate-global Error enum that contains all possible errors, and each module just uses a “sub-enum” of errors, i.e. the global enum is just the union of the module enums (which might overlap, of course). Now that you’re already creating types for the error enum variant, is there some support for this, or could there be? I imagine creating the Error enum as written, and then in a submodule just define something like

#[derive(SnaufSubError)]
enum Module1Error { Err1, Err2}

where Err1, Err2 are just variants of Error.

(Sorry, I hope I did describe that in an understandable way :D)

1 Like
#4

Well, a lot of people are using failure with one of the scenarios described in the guide for failure. It would help understanding your crate if you could explain how it compares/relates/combines to using failure.

3 Likes
#5

I have this same question. Is Snafu a drop-in replacement for Failure? If it is, how does a Snafu-based crate interop with a Failure-based crate? (How do Failure-based crates process Snafu-based errors and vice versa?). I assume that it does, and I think I see the path to it, but it would be good to know the intended way.

1 Like
#6

Yes, that’s what I advocate. Something like this:

mod alpha {
    #[derive(Debug, Snafu)]
    enum Error { ... }
    
    fn a1() -> Result<(), Error> { ... }
    fn a2() -> Result<(), Error> { ... }
}

mod beta {
    #[derive(Debug, Snafu)]
    enum Error { ... }
    
    fn b1() -> Result<(), Error> { ... }
    fn b2() -> Result<(), Error> { ... }
}

#[derive(Debug, Snafu)]
enum Error {
    SomethingRelevantToUsingA1 { source: alpha::Error }
    SomethingRelevantToUsingA2 { source: alpha::Error }
    SomethingRelevantToUsingB1 { source: beta::Error }
    SomethingRelevantToUsingB2 { source: beta::Error }
}

You’ll note that the top-level errors don’t have variants like “Alpha”, but instead focus on what you were trying to do — that’s the “context” in “context selector”. I wouldn’t advocate for having error variants that are the same as the underlying error type. This is something that I see often but it doesn’t help the people who experience the error. I already know it’s an IO-related error, but tell me what you were trying to do: was it “open the config file” or “save changes to the config file”?

3 Likes
#7

I would not consider myself to have throughly used Failure, so I’m hesitant to provide a head-to-head comparison due to the risk of being biased (because obviously SNAFU is better :wink:).

Here’s my thoughts on these points:

  • Strings as errors

If you want this, then you might as well just use Box<dyn Error>:

fn example() -> Result<(), Box<dyn std::error::Error>> {
    Err(format!("Something went bad: {}", 1 + 1))?;
    Ok(())
}

I don’t see the benefit that Failure provides here. If you wanted to do something similar with SNAFU, I’d say something like:

#[derive(Debug, Snafu)]
enum Error {
    #[snafu(display("Something went bad: {}", value))]
    WentWrong { value: i32 },
}

fn example() -> Result<(), Error> {
    WentWrong { value: 1 + 1 }.fail()?;
    Ok(())
}
  • A Custom Fail type
  • Using the Error type

These two forms are the bread-and-butter of SNAFU, and appear to avoid the downsides listed in the guide: you don’t have to have a 1-1 relationship between underlying error and error variant, it’s not required to allocate, and you can pass along any extra information that you need:

#[derive(Debug, Snafu)]
enum Error {
    #[snafu(display(r#"Could not parse the area code from "{}": {}"#, value, source))]
    AreaCodeInvalid {
        value: String,
        source: ParseIntError,
    },
    #[snafu(display(r#"Could not parse the phone exchange from "{}": {}"#, value, source))]
    PhoneExchangeInvalid {
        value: String,
        source: ParseIntError,
    },
}

fn example(area_code: &str, exchange: &str) -> Result<(), Error> {
    let area_code: i32 = area_code
        .parse()
        .context(AreaCodeInvalid { value: area_code })?;
    let exchange: i32 = exchange
        .parse()
        .context(PhoneExchangeInvalid { value: exchange })?;
    Ok(())
}
  • An Error and ErrorKind pair

If you choose to make your error type opaque for API concerns, you can still implement any methods you want on the opaque type, choosing very selectively what your public API is:

#[derive(Debug, Snafu)]
enum InnerError {
    MyError1 { username: String },
    MyError2 { username: String },
    MyError3 { address: String },
}

#[derive(Debug, Snafu)]
struct Error(InnerError);

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum ErrorKind {
    Authorization,
    Network,
}

impl Error {
    fn kind(&self) -> ErrorKind {
        use InnerError::*;

        match self.0 {
            MyError1 { .. } | MyError2 { .. } => ErrorKind::Authorization,
            MyError3 { .. } => ErrorKind::Network,
        }
    }

    fn username(&self) -> Option<&str> {
        use InnerError::*;

        match &self.0 {
            MyError1 { username } | MyError2 { username } => Some(username),
            _ => None,
        }
    }
}

I would not call it a drop-in replacement because the API differs between the two crates. To me, “drop-in” indicates I could just change “failure” to “snafu” in Cargo.toml and everything would continue working.

SNAFU errors can use any type that implements Error as an underlying causes. In the original example, I show Box<dyn Error> as one example of that. If you are using a crate that uses Failure to build its errors, you can trivially wrap them inside of SNAFU errors. Is there any other kind of interoperation you were thinking about?

2 Likes