graphql-engine/v3/docs/errors.md
Rakesh Emmadi 134f48a7f1 Contributing guide for error management in Rust (#554)
Add a document that lays down some recommendations around design and
usage or error types in Rust code.

V3_GIT_ORIGIN_REV_ID: 3b2489bf632c96abcac0cd0966a675aa1e15e1bc
2024-05-10 12:28:28 +00:00

5.5 KiB

Error Management in Rust

This document offers recommendations for raising and handling exceptions. It also outlines the design and usage of error types in Rust code.

Table of Contents

Understanding the Result Type

Exceptions in Rust code are raised by returning a value of Result<T, E> type, where T is the type of success value and E is the error type.

fn my_function() -> Result<T, E> {}

The Question Mark operator

Result return values can be automatically unwrapped and chained using the ? operator, pronounced "try". This allows us to write code that looks flat, but will actually return early with an error if one occurs. (Haskell programmers may liken this to monadic binding with >>=; ? is similar but with limited ability)

You can use it as follows (imagine every function call return a Result<i32, ErrorType>);

function display_very_important_data() -> Result<i32, ErrorType> {
    let user = authenticate_user()?;
    let data = retrieve_very_important_data_for(user)?;
    for item in data {
        emit(item)?;
    }
    Ok(data.len()) // you must wrap the return value if it is "pure"
}

For this to work, the errors must all be of same type, or they must be convertible into the given error type way of From implementation. If neither of these is the case, you can convert the error explicitly using .map_err(|err| ...).

Designing Error Types

In a typical application code, errors can be of several kinds and are handled differently. One approach is to model the error type as only a String value. But it makes the classification of errors cumbersome while handling them. Generally, the error types are enum types where each variant corresponds to a specific kind of error.

enum Error {
    UserNotFound,
    InvalidPassword,
    PasswordNotMatched,
}

As the application grows, the error enum becomes overwhelmed with numerous variants, making handling difficult.

fn handle_error(err: Error) {
    match err {
        Error::UserNotFound => {set_status(404)},
        Error::InvalidPassword => {set_status(400)},
        Error::PasswordNotMatched => {set_status(401)},
        // Code here will be expanded with more error variants.
    }
}

For reference, the GraphQL API execution layer in engine crate alone has a few tens of errors in number.

It is essential to break down error enums with fewer variants for better handling and readability. But, how do we break them? We need to understand the errors broadly, such as where they originate (scope of error) and whether we can group errors by any common attribute or kind. Let's discuss these aspects below.

Scope-based Error handling

A scope within an application's code is defined as an isolated execution layer that handles a specific task.

For instance, consider a user authentication application with several execution layers responsible for request validation, fetching user information, password management and response building. Each layer needs to produce its own error type, encompassing only those variants raised within the layer. The application's overarching error type should encompass variants representing errors specific to each scope, alongside a handful of miscellaneous individual errors.

#[derive(thiserror::Error, Debug)]
enum AppError {
    #[error("request error: {0}")]
    Request(#[from] RequestError),
    #[error("user error: {0}")]
    User(#[from] UserError),
    #[error("password error: {0}")]
    Password(#[from] PasswordError),
    #[error("response error: {0}")]
    Response(#[from] ResponseError),
    #[error("some other error occurred")]
    SomeOtherError,
}

The #[from] attribute will enable the conversion of errors into AppError without the need to map and construct the variant. This conversion is handled for you automatically by using ? operator.

In rust, scopes within an application, generally, referred by modules and submodules.

Modules as Error Boundaries

It is recommended to have a error.rs submodule to host all error-related code, including types, traits etc. All functions in a module should only return the error type specified by the module, thus restricting the usage of any external errors.

|- user/
    |- error.rs
    |- mod.rs
    |- types.rs
// error.rs
pub enum UserError { ... }
// types.rs
pub struct UserInfo { ... }
// mod.rs
mod error;
mod types;
fn get_user_info(username: &str) -> Result<UserInfo, UserError> { ... }

Categorizing Errors by Kind

If feasible, variants within an error type can be consolidated into a new error type based on their similarities. For example, all unexpected internal errors can be encompassed in an enum InternalError { ... } type.

#[derive(thiserror::Error, Debug)]
pub enum UserError {
    #[error("username {0} is not valid")]
    InvalidUserName(String),
    #[error("internal error: {0}")]
    Internal(#[from] InternalError),
}

#[derive(thiserror::Error, Debug)]
pub enum InternalError {
    #[error("DB exception: {0}")]
    DatabaseException(#[from] DBException),
}