risingwave_error

Macro def_anyhow_newtype

source
macro_rules! def_anyhow_newtype {
    (@from $error:ident transparent) => { ... };
    (@from $error:ident $context:literal) => { ... };
    (
        $(#[$attr:meta])* $vis:vis $name:ident
        $(, $from:ty => $context:tt)* $(,)?
    ) => { ... };
}
Expand description

Define a newtype wrapper around anyhow::Error.

§Usage

def_anyhow_newtype! {
   /// Documentation for the newtype.
   #[derive(..)]
   pub MyError,

   // Default context messages for each source error type goes below.
   mysql::Error => "failed to interact with MySQL",
   postgres::Error => "failed to interact with PostgreSQL",
   opendal::Error => transparent, // if it's believed to be self-explanatory
                                  // and any context is not necessary
}

§Construction

Unlike anyhow::Error, the newtype CANNOT be converted from any error types implicitly. Instead, it can only be converted from anyhow::Error by default.

  • Users are encouraged to use anyhow::Context to attach detailed information to the source error and make it an anyhow::Error before converting it to the newtype.

  • Otherwise, specify the default context for each source error type as shown in the example above, which will be expanded into a From implementation from the source error type to the newtype. This should NOT be preferred in most cases since it’s less informative than the ad-hoc context provided with anyhow::Context at the call site, but it could still be useful during refactoring, or if the source error type is believed to be self-explanatory.

To construct a new error from scratch, one can still use macros like anyhow::anyhow! or risingwave_common::bail!. Since bail! and ? already imply an into() call, developers do not need to care about the type conversion most of the time.

§Example

fn read_offset_from_mysql() -> Result<String, mysql::Error> {
    ..
}
fn parse_offset(offset: &str) -> Result<i64, ParseIntError> {
    ..
}

fn work() -> Result<(), MyError> {
    // `mysql::Error` can be converted to `MyError` implicitly with `?`
    // as the default context is provided in the definition.
    let offset = read_offset_from_mysql()?;

    // Instead, `ParseIntError` cannot be directly converted to `MyError`
    // with `?`, so the caller must attach context explicitly.
    //
    // This makes sense as the semantics of the integer ("offset") holds
    // important information and are not implied by the error type itself.
    let offset = parse_offset(&offset).context("failed to parse offset")?;

    if offset < 0 {
        // Construct a new error with `bail!` macro.
        bail!("offset `{}` must be non-negative", offset);
    }
}

§Discussion

  • What’s the purpose of the newtype?

    • It is to provide extra type information for errors, which makes it clearer to identify which module or crate the error comes from when it is passed around.
    • It enforces the developer to attach context (explicitly or by default) when doing type conversion, which makes the error more informative.
  • Is the effect essentially the same as thiserror?

    • Yes, but we’re here intentionally making the error type less actionable to make it informative with no fear.

    • To elaborate, consider the following thiserror example:

      #[derive(thiserror::Error, Debug)]
      pub enum MyError {
          #[error("failed to interact with MySQL")]
          MySql(#[from] mysql::Error),
          #[error(transparent)]
          Other(#[from] anyhow::Error),
      }

      This gives the caller an illusion that all errors related to MySQL are under the MySql variant, which is not true as one could attach context to an mysql::Error with anyhow::Context and make it go into the Other variant.

      By doing type erasure with anyhow, we’re making it clear that the error is not actionable so such confusion is avoided.