Skip to content

Holo Guide ‐ [error.rs] how to structure our errors.

Paul-weqe edited this page Sep 13, 2024 · 8 revisions

error.rs is a file that does not depend on any other file inside the holo-vrrp and thus is the most independent file on the module. It just describes the errors we will be using through the module, how they are structured and how we will be tracing them.

General error handling in Rust

In Rust, errors are handled using the Result and Option types. The Result<T, E> type is used for operations that can succeed(Ok(T)) or fail (Err(E)), while Option is for values that might exist(Some(T)) or not exist(None). Rust encourages handling errors explicitly, avoiding exceptions. By implementing the std::error::Error trait, custom error types can be created and propagated throughout the program, ensuring robust error management.

Errors are key, and as your module gets bigger you may introduce new custom errors or remove some unused error types depending on what exactly you will be needing.

For VRRP, we will define the key errors based on what we have defined in our VRRP yang model.

interpreting the errors in our yang model into our error.rs file.

There are various errors that we aim to maintain across our different modules. Notably, these include IoError and InterfaceError, which are global errors that exist independently of the specific module or layer in which we are operating.

After these are general errors are described, we shift our focus to our protocol specific errors, which in this case are vrrp specific errors. These are defined within our VRRP YANG model. Here is a yang description of an error from line 213 to line 242, where the vrrp global errors are defined.

Among these errors we define the following checksum-error, ip-ttl-error, version-error and vrid-error; all defined under vrrp-error-global. These errors help ensure consistency with the VRRP standard, as defined in our YANG model.

Let us see how we describe this vrrp-error-global and its child errors inside our error.rs.

// error.rs
use std::fmt::Debug;

#[derive(Debug)]
pub enum GlobalError {
    ChecksumError,
    IpTtlError,
    VersionError,
    VridError,
}

impl std::fmt::Display for GlobalError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            GlobalError::ChecksumError => {
                write!(f, "incorrect checksum received")
            }
            GlobalError::IpTtlError => {
                write!(f, "invalid ttl received. IP ttl for vrrp should always be 255")
            }
            GlobalError::VersionError => {
                write!(
                    f,
                    "invalid VRRP version received. only version 2 accepted"
                )
            }
            GlobalError::VridError => {
                write!(f, "vrid received is not amongst the configured VRIDs")
            }
        }
    }
}

This is a solid start for defining how we structure our errors. The GlobalError enum encapsulates the VRRP-specific errors, and by implementing both Debug and Display, we ensure that these errors can be displayed effectively.

However, it is important to note that, despite its name, GlobalError refers to global errors within the VRRP module and not the whole of holo module. We also have general errors, which apply across multiple modules. For instance, the IoError enum represents errors that are not specific to VRRP but apply to various networking operations.

Here is how the IoError is defined:

// error.rs
use std::fmt::Debug;
use std::net::IpAddr;

use tracing::{warn, warn_span};

#[derive(Debug)]
pub enum IoError {
    SocketError(std::io::Error),
    MulticastJoinError(IpAddr, std::io::Error),
    MulticastLeaveError(IpAddr, std::io::Error),
    RecvError(std::io::Error),
    RecvMissingSourceAddr,
    SendError(std::io::Error),
}

impl std::fmt::Display for IoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IoError::SocketError(..) => {
                write!(f, "failed to create raw IP socket")
            }
            IoError::MulticastJoinError(..) => {
                write!(f, "failed to join multicast group")
            }
            IoError::MulticastLeaveError(..) => {
                write!(f, "failed to leave multicast group")
            }
            IoError::RecvError(..) => {
                write!(f, "failed to receive IP packet")
            }
            IoError::RecvMissingSourceAddr => {
                write!(
                    f,
                    "failed to retrieve source address from received packet"
                )
            }
            IoError::SendError(..) => {
                write!(f, "failed to send IP packet")
            }
        }
    }
}

Putting the errors under one roof

So far, things are looking good—we’ve defined one group of VRRP-specific errors (GlobalError) and another for general errors (IoError). Both of these error groups have implemented the std::fmt::Display trait, allowing us to print out the errors as needed.

Next, we need to unify these under a single struct, which we’ll call Error. This struct will encapsulate both the general errors and the VRRP-specific errors, providing a cohesive way to handle all errors across the module. Here's how the Error struct looks like so far:

// error.rs
use std::fmt::Debug;
use std::net::IpAddr;

use tracing::{warn, warn_span};

#[derive(Debug)]
pub enum Error {
    // I/O errors
    IoError(IoError),

    // vrrp-ietf-yang-2018-03-13 specific errors
    GlobalError(GlobalError),
}


#[derive(Debug)]
pub enum IoError {
   // defined above...
}


#[derive(Debug)]
pub enum GlobalError {
    // defined above...
}

With this setup, whenever an error occurs, we will call something like Error::GlobalError(/*GlobalError instance*/), depending on the type of error encountered. This ensures that both general and protocol-specific errors are handled in a unified manner.

Additionally, while we’ve focused on VRRP-specific errors based on the YANG model, you can extend this approach by adding custom errors that are not defined in the YANG model. However, it’s important to proceed with caution when doing so, ensuring that these custom errors are well-defined and fit logically within your protocol's overall structure.

Logging error like we mean it

We've defined our error structures, but one of the main reasons for having errors in the first place is to log them. As engineers, we rely on these logs to troubleshoot and trace what went wrong during execution.

For logging, we will define a method named log(&self) in the Error implementation. This method will enable structured logging using the tracing crate. If you're unfamiliar with the difference between logging and tracing, this article explains it in detail.

Here’s how we define the log(&self) method inside our Error struct:

//error.rs
impl Error {
    pub(crate) fn log(&self) {
        match self {
            Error::IoError(error) => {
                error.log();
            }
            Error::GlobalError(error) => {
                match error {
                    GlobalError::ChecksumError => {
                        warn_span!("global_error").in_scope(|| { warn!("invalid checksum received") })
                    },
                    GlobalError::IpTtlError => {
                        warn_span!("global_error").in_scope(|| { warn!("TTL for IP packet is not 255.") })
                    },
                    GlobalError::VersionError => {
                        warn_span!("global_error").in_scope(|| { warn!("invalid version received. only version 2 accepted.") })
                    },
                    GlobalError::VridError => {
                        warn_span!("global_error").in_scope(|| { warn!("vrid is not locally configured. ") })
                    },
                }
            },
        }
    }
}

impl IoError {
    pub(crate) fn log(&self) {
        match self {
            IoError::SocketError(error) => {
                warn!(error = %with_source(error), "{}", self);
            }
            IoError::MulticastJoinError(addr, error)
            | IoError::MulticastLeaveError(addr, error) => {
                warn!(?addr, error = %with_source(error), "{}", self);
            }
            IoError::RecvError(error) | IoError::SendError(error) => {
                warn!(error = %with_source(error), "{}", self);
            }
            IoError::RecvMissingSourceAddr => {
                warn!("{}", self);
            }
        }
    }
}

Above, we have defined a log() method inside IoError, while we have had GlobalError manually define it's various types and their logging inside the impl Error's log(&self) method. You can choose either one of these, or a mixture of these to describe your logs

Where are we so far ?

So, what have we achieved in terms of error handling so far?

  • First, we made a clear distinction between two error types: general errors found across multiple modules and protocol-specific errors.

  • Second, we explored our VRRP YANG module and found the vrrp-global-error group, which represents VRRP-specific errors. We created an enum for them (GlobalError), implemented the Display trait to allow proper error printing, and defined another enum (IoError) for general errors that span across protocol implementations in holo. Both enums were structured to print errors effectively for troubleshooting.

  • Third, we unified these two error groups under a single Error enum. Although IoError and GlobalError are just a starting point, they serve as a blueprint for how to define and handle errors in future modules.

  • Finally, we implemented a log method to log both GlobalError and IoError using the tracing crate. In holo, all logs are saved to /var/log/holod.log, ensuring that errors are tracked and logged in a structured and consistent manner.

What next?

We have so far created two main error groups, which is GlobalError and IoError, then placed them inside the Error enum.

However, we have more error groups, such as VirtualRouterError which have not yet been described here.

In the end, our Error enum should look like the following:

// error.rs
#[derive(Debug)]
pub enum Error {
    // I/O errors
    IoError(IoError),
    InterfaceError(String),

    // vrrp-ietf-yang-2018-03-13 specific errors
    GlobalError(GlobalError),
    VirtualRouterError(VirtualRouterError),
}

This is the link to the whole error.rs file.

The file describes a lot more details that what we have covered in this guide so far. Although the file is lengthy, if you did go through this guide and understood the basics of how we build our errors, you should be comfortable to go through the file without much trouble. Copy and paste the whole file into your error.rs and take your time to go through it. It is a lengthy file, but not too complicated; taking your time to go through it will definitely be helpful to you in the long run.

Checkpoint.

Let us test to make sure everything is running successfully. From our project's root directory, let us run:

cargo +nightly build

If this runs successfully, then everything so far is fine, and we haven't broken anything. We can create a commit showing what we have done so far:

git add .
git commit 

And write the following commit message:

vrrp: create and describe errors inside error.rs

We have created error definitions inside the 
error.rs file. Two main types of errors created:

- general errors which are used across most protocols
- protocol specific errors, mostly described inside the 
   protocols YANG model. 
The error definitions have had `Display` trait implemented 
allowing for them to be printed out and `log()` method has 
been implemented in the main `Error` enum allowing for 
logging when the service is already running. Both will be 
used for troubleshooting. 

Signed-off-by: My Name <[email protected]>

Save that commit, and now we have properly defined errors which we can use in future.

Note: You may have noticed a pattern with how our commits are structured:

[protocol/issue]: [brief-description]

[detailed description]

Signed-off-by: [Full Name] <[email]>

This is the general way we structure the commit messages inside holo, and we would urge for you to structure the same way, for uniformity purposes.

With our errors defined, let us look at our packet.rs next.