Design pattern: feedback on error handling in Rust

Hello ! I am exploring rust by developing my open source crate https://crates.io/crates/newton_rootfinder
It is not much but I am learning a lot by developing it.

The next stop in my journey is to introduce error handling.
I would love to have some feedback on the design idea I am going to implement, as I am not experienced in Rust (and in error handling in general)

Simplified crate presentation

Usually, if you use a crate, you will use its functions by providing simple objects as inputs and using the outputs.
In some cases however, the crate can accept some complicated object as input
(Think of a function with the signature fn crate_func(fn(f64) -> f64), the user of that function would have to inject its own function matching the signature to use it)

I tried in my crate to push that concept all the way, as this crate is implementing an algorithm that act on a user provided model, and I wanted to have a tight integration with that model.

I already defined the kind of model the algorithm is working on throught the Model trait, but now I want to go further and also define some errors the user model can raise, in order to properly react to them.

From the algorithms point of view, there are three phases, and the user model is called during all those phases:

  • initialization
  • iterations
  • final evaluation

If the user model raise an error, depending of the phase it can be recoverable or not, usually by performing a rollback.

I classified the model errors categories as the following:

  • numerical values exist but are inaccurate (it is always recoverable except if it happens in the final evaluation). In this case the algorithm can continue with hope it will recover before the final evaluation
  • unusable numerical values such as NaN, None, random values or default values (it is recoverable if it happens during the iterations phase)
  • unrecoverable (but not a panic), the algorithm stops if that happens.

With those errors, I would like to:

  • log them to a simulation log
  • perform some logic to try to recover from them
  • change the final status of algorithm if a error is raised during the final evaluation

I read Error Handling In Rust - A Deep Dive | A learning journal and my first thought of implementation was to implement an error enum:

pub enum ModelError {
    InaccurateValuesError(Box<dyn std::error::Error>),
    UnusableValuesError(Box<dyn std::error::Error>),
    UnrecoverableError(Box<dyn std::error::Error>)
}

Such an enum is great as I can perform all of my logic to try to recover from an error and I can also log it.
However, I would like the user to be able to define its own subcategories of errors:
For example, an InaccurateValuesError could come from an extrapolation in a table or a non convergence in an internal function and the user would like to raise InaccurateValuesError(ExtrapolationError(String)) or InaccurateValuesError(NonConvergenceError(String)).
That's why I chose to box in my ModelError enum the standard error trait.

My point there is that I don't know the classification of the user and I would like to allow any classification/subclassification, but I would like to be able to log it with all the informations provided.

Questions

General design

First of all, does the design presented before sounds correct and is there any room for improvement ?

Any (constructive) criticism is welcome !
Any link to any external resources is welcome !

Thanks in advance for your feedback.

Specific question: wrapper around the new return type

In my crate, the Model trait allows for integeration with my algorithm.
However, it can be hard to use and to simplify the most basic cases, I provide a UserModelFromFunction struct already implementing that trait to facilitate the user experience.

Instead of having to implement the Model trait, the users can only provide one function and instanciate the struct UserModelFromFunction.

In the previous version, I had the following signatures (simplified):

pub trait Model {
  fn evaluate(&mut self);
  fn get_outputs(&self) -> f64;
}

pub struct UserModelFromFunction {
    pub inputs: f64,
    pub func: fn(f64) -> f64,
    pub outputs: f64
}

impl Model for UserModelFromFunction {
  fn evaluate(&mut self) {
    outputs = self.func(self.inputs);
  }
  fn get_outputs(&self) -> f64 {
    outputs
  }
}

In the new version of the trait:

pub trait Model {
  fn evaluate(&mut self) -> Result<(), ModelError>;
  fn get_outputs(&self) -> f64;
}

However, in the basic version, I don't want the user to think of the failures modes,
and I am going to assume that values are valid and otherwise it would panic.
(If the users want to benefit of the error handling, they should not use the basic UserModelFromFunction).

Which means I would like to implement something along those lines:

pub struct UserModelFromFunction {
    pub inputs: f64,
    pub func: fn(f64) -> f64,
    pub outputs: f64
}

impl Model for UserModelFromFunction {
  fn evaluate(&mut self) -> Ok(()) {
    outputs = self.func(self.inputs);
  }
  fn get_outputs(&self) -> f64 {
    outputs
  }
}

The previous implementation does not work as the return type of the evaluate function is not Result.

I could fix it by doing fn evaluate(&mut self) -> Result<(), ModelError> to match the trait signature,
but I find a bit weird to add ModelError as no such errors can be raised in that case.
I was wondering if there is any other way to fix that piece of code.

The question reformulated would be: can we use a trait with a Result signature with only the successful variant without having to match the unreachable error modes (if the errors are also defined by the trait)

Thanks a lot for your time !

1 Like

Alright…

Seems okay to me, in principle. You could avoid the need for Box<dyn …> though with associated types on the trait instead. This also helps with your other problem

One approach to take here is to follow the example of traits like TryFrom in the standard library.

The implementation for the trait can define the custom error type, furthermore infallible implementations can use std::convert::Infallible (as e.g. std does it in a generic implementation based on Into).

So the approach could be to do

pub enum ModelError<M: Model> {
    InaccurateValuesError(M::InaccurateValuesError),
    UnusableValuesError(M::UnusableValuesError),
    UnrecoverableError(M::UnrecoverableError),
}

pub trait Model: Sized {
  type InaccurateValuesError: Error;
  type UnusableValuesError: Error;
  type UnrecoverableError: Error;
  fn evaluate(&mut self) -> Result<(), ModelError<Self>>;
  fn get_outputs(&self) -> f64;
}

or

pub enum ModelError<A, U, R> {
    InaccurateValuesError(A),
    UnusableValuesError(U),
    UnrecoverableError(R),
}

pub trait Model {
    type InaccurateValuesError: Debug + Display;
    type UnusableValuesError: Debug + Display;
    type UnrecoverableError: Debug + Display;
    fn evaluate(
        &mut self,
    ) -> Result<
        (),
        ModelError<
            Self::InaccurateValuesError,
            Self::UnusableValuesError,
            Self::UnrecoverableError,
        >,
    >;
    fn get_outputs(&self) -> f64;
}

Then the implementation that never errors could do

pub struct UserModelFromFunction {
    pub inputs: f64,
    pub func: fn(f64) -> f64,
    pub outputs: f64
}

use std::convert::Infallible;

impl Model for UserModelFromFunction {
  type InaccurateValuesError = Infallible; 
  type UnusableValuesError = Infallible;
  type UnrecoverableError = Infallible;
  fn evaluate(&mut self) -> Result<(), ModelError<Self>> {
    self.outputs = (self.func)(self.inputs);
    Ok(())
  }
  fn get_outputs(&self) -> f64 {
    self.outputs
  }
}

You’d still syntactically be working with Results, but at run-time there’s never the possibility for an error, and the Result doesn’t have any performance overhead.

If you want to take the TryFrom/From analogy further, you could provide a second trait with a default implementation:

use std::error::Error;
use std::fmt;

pub enum ModelError<M: FallibleModel> {
    InaccurateValuesError(M::InaccurateValuesError),
    UnusableValuesError(M::UnusableValuesError),
    UnrecoverableError(M::UnrecoverableError),
}

pub trait FallibleModel: Sized {
    type InaccurateValuesError: fmt::Debug + fmt::Display;
    type UnusableValuesError: fmt::Debug + fmt::Display;
    type UnrecoverableError: fmt::Debug + fmt::Display;
    fn try_evaluate(&mut self) -> Result<(), ModelError<Self>>;
    fn get_outputs(&self) -> f64;
}
pub trait Model: FallibleModel {
    fn evaluate(&mut self);
    // not using `self` on the other methods in order to avoid
    // ambiguities when doing method calls
    fn get_outputs(this: &Self) -> f64;
}

impl<M: Model> FallibleModel for M {
    type InaccurateValuesError = Infallible;
    type UnusableValuesError = Infallible;
    type UnrecoverableError = Infallible;
    fn try_evaluate(&mut self) -> Result<(), ModelError<Self>> {
        self.evaluate();
        Ok(())
    }
    fn get_outputs(&self) -> f64 {
        Model::get_outputs(self)
    }
}

pub struct UserModelFromFunction {
    pub inputs: f64,
    pub func: fn(f64) -> f64,
    pub outputs: f64,
}

use std::convert::Infallible;

impl Model for UserModelFromFunction {
    fn evaluate(&mut self) {
        self.outputs = (self.func)(self.inputs)
    }
    fn get_outputs(this: &Self) -> f64 {
        this.outputs
    }
}

pub struct AnotherUserModelFromFunctionButFallible {
    pub inputs: f64,
    pub func: fn(f64) -> f64,
    pub outputs: f64,
}

#[derive(Debug)]
pub enum CustomEnum {
    Variant1,
    Variant2,
}
impl fmt::Display for CustomEnum {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        match self {
            CustomEnum::Variant1 => write!(f, "An error occurred, variant one"),
            CustomEnum::Variant2 => write!(f, "An error occurred, variant two"),
        }
    }
}
impl Error for CustomEnum {}

impl FallibleModel for AnotherUserModelFromFunctionButFallible {
    // diverse example of some options what kind of error types could be used

    // always accurate
    type InaccurateValuesError = Infallible;
    // using string literals
    type UnusableValuesError = &'static str;
    // custom enum for unrecoverable errors
    type UnrecoverableError = CustomEnum;
    // could've been using Box<dyn Error> after all, too, e.g.
    // type InaccurateValuesError = Box<dyn Error>

    fn try_evaluate(&mut self) -> Result<(), ModelError<Self>> {
        self.outputs = (self.func)(self.inputs);
        let condition = false; // TODO
        if condition {
            Err(ModelError::UnusableValuesError("error 'Foo' happened!"))
        } else {
            Ok(())
        }
    }
    fn get_outputs(&self) -> f64 {
        self.outputs
    }
}
8 Likes

Thanks a lot for your time ! I am currently working on the solution you suggested ! (GitHub - Nateckert/newton_rootfinder at wip_errors)
There are some small bumps I hit on the road but I followed your general direction. I still have to polish some things but it is looking promising.

Thanks a lot !

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.