Add a document that lays down some recommendations around design and usage or error types in Rust code. V3_GIT_ORIGIN_REV_ID: 3b2489bf632c96abcac0cd0966a675aa1e15e1bc
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),
}