SNAFU 0.2.1 Released

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.

6 Likes

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

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)

2 Likes

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

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

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"?

5 Likes

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

I'm giving snafu a try, but I'm confused about a number of things:

  • the macro creates structs from the enum variants. Why is this? Why not just use enum variants? I'm also not sure what to use when. I see from the examples that context uses the struct. If a user wants to know what type of error something is, can they do x == Error::Variant or should they do x == Variant or should they match? Do I implement PartialEq taking into account only the discriminants, to allow not matching the data? Also these structs are generic yet the enum variants have to annotate explicit types when having data?
  • the context method requires that the original error is included as source on the enum variant? What if I want to make one variant from different underlying error types? For now I use map_err rather than context. It seems simpler. It also seems logical to use map_err when I don't really want to keep the underlying error.
  • I also found that there is no conversion to a Box<dyn std::error::Error + Send>. I wonder if it would be good to generate that where the enum is Send? Just for when code might have to send a result across threads.

Ok, I know, it's a lot of questions...

2 Likes

Enum variants are not themselves types, so that would be a non-starter. Even in a world where they are types, the ergonomics of the library would be reduced to use them. For example, a variant might contain a source and a backtrace, two fields that will be automatically provided for you by the library. If you used the variants directly, you'd have to write code like:

// current
something().context(MyContext { user_id })?;

// Directly using the enum variant
something().map_err(|source| MyError::MyContext { source, backtrace: Backtrace::default(), user_id })?;

Additionally, there's a benefit that these structs are internal to your crate by default, which means that traits implemented on them are also internal. This means that there's no way for a user of your crate to attempt to create one of your errors from a "leaked" implementation of From, for example.

In 99.9% of the cases, use the created struct when you are constructing / wrapping an error. Use the variant when you are handling an error.

This depends on what you mean by "user". Remember that if you expose an enum publicly, then you cannot add (for now) or remove any variants or fields to the enum without SemVer-breaking your API.

If you are happy with that being part of your API, then yes, users can match.

See also these two references:

The section about ErrorKind here

The structs are generic so that you can avoid contortions when converting from borrowed to owned types. Errors typically don't want to have reference types in them (although SNAFU supports this). However, you only want to perform that allocation in case of failure. For example:

#[derive(Debug, Snafu)]
enum Error {
    Boom { user_name: String },
}

fn login(name: &str) -> Result<(), Error> {
    real_login().context(Boom { name })?; // Note no call to `to_string` or equivalent
    Ok(())
}

It's possible to create error types with generic types or lifetimes, it's just less common:

#[derive(Debug, Snafu)]
enum Error<T, U> 
where
    U: Display,
{
    Boom { payload: T },
    Bang { payload: U },
}

Yes, or you can make a "leaf" error and create it via MySelector { ... }.fail().

I do not believe that this is good practice, so the crate does not encourage it. Conceptually, this just doesn't make sense to me. By lumping all of your errors into one, you lose the ability to provide useful information to the receiver of the error. You might as well just return a String or Box<Error> at that point.

Can you expand on why you feel that doing this is good for your API?

Absolutely! SNAFU isn't intended to be the simplest possible solution, but it is aimed at being the simplest while providing an amazing, feature-full experience to make world-class error reporting.

In which cases do you want to not include as much information about the error as possible? Again, I don't see the benefit.

Can you expand on what you mean by this? This code compiles for me with SNAFU 0.2.3:

use snafu::Snafu;

#[derive(Debug, Snafu)]
enum Error {
    Dummy,
}

fn accept(_: Box<dyn std::error::Error + Send>) {}

fn try_it(e: Error) {
    accept(Box::new(e));
}

Thanks for explaining all that.

For me the error type is a first class citizen of the API. So much so that's it is it's only reason of existence. My crate now has almost full functionality, and until now I just threw all errors in failure::Error, for simplicity. Only now when finalizing the API and writing unit tests to verify which errors get thrown when, I realize failure::Error requires downcasting to get any info out of it, so it's not great for API. Other than that, during the whole development of the crate I never ever have use of errors. It will be exactly documented which errors might be returned and what that means.

The error is a special scenario, out of the ordinary, usually not the desired scenario. It requires a decision, "what to do now an error has occurred?" As a library author I can not make such decisions. It's up to client code to make the decisions.

This is an example situation:

impl<A, M> Recipient<M> for Addr<A>

   where  A: Actor + Handler<M> ,
          M: Message            ,

{
   type Error = ThesError;

   fn call( &mut self, msg: M ) -> Return< Result<<M as Message>::Return, Self::Error> >
   {
      Box::pin( async move
      {
         let (ret_tx, ret_rx)     = oneshot::channel::<M::Return>();
         let envl: BoxEnvelope<A> = Box::new( CallEnvelope::new( msg, ret_tx ) );


         await!( self.mb.send( envl ) ).map_err( |e|
         {
            if e.is_full()
            {
               ThesError::MailboxFull{ actor: format!( "{:?}", self ) }
            }

            else // is disconnected
            {
               ThesError::MailboxClosed{ actor: format!( "{:?}", self ) }
            }

         })?;


         await!( ret_rx )

            .map_err( |e| ThesError::MailboxClosedBeforeResponse{ actor: format!( "{:?}", self ) } )
      })
   }

   ...
}

The first error is futures::channel::mpsc::SendError. It can basically be full if a bounded channel, or disconnected if the other end of the channel is dropped. That should only happen here if it's in another thread and the thread panics, because until self.mb (the Sender part of the channel) gets dropped, the mailbox will continue to live.

The second one is the oneshot channel for the response. It can give a Canceled error if the Sender part is dropped (the thread panicked just like above) The only difference is that it was still running when we send the message, but panicked before it could send us the response.

I don't see what the possible value is of including the underlying errors, on the contrary they are implementation details. And they hold absolutely no interesting information. The only reason I might end up making MailboxClosedBeforeResponse is that it might be useful for debugging to know that the mailbox thread only crashed after we successfully send the request. Otherwise the two different underlying errors would both translate to MailboxClosed. Note that this is already a level of granularity that a similar library like actix won't give you.

Since this object keeps the mailbox alive unless a thread panics, the documentation will explain that, and I feel that's about all that can be said about it.

So I don’t really see how to use context here?

1 Like

I mostly agree, with the distinction that there are (at least) two users:

  • The creator of the crate
  • The user of the crate

The creator often wants to be able to do specific inspection of the error and the related properties. They want to be able to make assertions on the error and they aren't worried about SemVer-breakage because it's not a "public" API, as far as they are concerned.

The user wants to do much of the same things, but the creator doesn't want to be overly restricted by what they promise in the API.

The primary mechanism SNAFU offers here is the concept of an opaque error. This allows for there to be an internal error type, completely exposed to the creator, while the public API has a very constrained API, allowing the creator to opt into everything specifically.

However, as the library author you do have to make decisions about what information is needed by your clients, as well as how it is grouped.

Contrast your example with that of file I/O. The operating system error provides moderately useful information like "file not found" which is beneficial to provide (at least in string form!) to the end consumer of the error.

I agree that exposing that you have an io::Error inside your own error type via a public API may not be a good idea in most cases, but the opaque error should address such a situation. Something like the ErrorKind pattern from the previous comment

I'm ignoring the futures-related aspects of your code for this non-compilable example:

#[derive(Debug, Snafu)]
enum InnerError {
    Channel { source: MpscError, actor: String },
    MailboxClosedBeforeResponse { source: MpscError, actor: String },
}

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

enum ErrorKind {
    MailboxFull,
    MailboxClosed,
    MailboxClosedBeforeResponse,
}

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

        match self.0 {
            Channel { source, .. } if source.is_full() => ErrorKind::MailboxFull,
            Channel { source, .. } => ErrorKind::MailboxClosed,
            MailboxClosedBeforeResponse { .. } => ErrorKind::MailboxClosedBeforeResponse,
        }
    }
}

impl Whatever {
    fn foo(&self) -> Result<(), InnerError> {
        self.mb.send().with_context(|| Channel { actor: format!("{:?}", self) })?;
        ret_rx.with_context(|| MailboxClosedBeforeResponse { actor: format!("{:?}", self) })?;
        Ok(())
    }
}

If there's truly no benefit to you to include the underlying error, then you might never use context. You could instead just use "leaf" errors.

Likewise, it's possible that SNAFU's design will never fit with the way you want to write your code. There's certainly no reason to force a square peg into a round hole — if a library doesn't seem like a good fit, check to see if there's a better one out there! Sometimes we don't understand a library and other times the library just isn't the right one.

Potentially relevant issues:

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.