Do not panic!

Effective Error Handling In Rust

About Me

Levente Krizsán (klevente.dev)

Backend Software Engineer @
Interested in Rust since 2020
Using it professionally since 2024

What Could Go Wrong Here?

app.post('/order', async (req, res) => {
  const user = await fetchUser(req.userId);
  const items = await orderProducts(req.body.products);
  const invoice = await generateInvoice(items);
  return {
    paymentUrl: buildPaymentUrl(invoice),
  };
});

What Do the Declarations Tell Us?

C++:

user fetch_user(std::string_view user_id);

C#:

class UserService { public async Task<User> fetchUser(String userId); }

TypeScript:

async function fetchUser(userId: string): User;

Java:

class UserService {
    public User fetchUser(String userId) throws NonExistentEntityException;
}

Error Handling Is Hard!

Have to deal with known & unknown unknowns!

Countless combinations of things can go wrong!

How Does Rust Fare in This Regard?

Rust promises:

  • Blazingly (🔥) fast™ performance
  • Memory efficiency
  • Memory-safety, thread-safety
  • A strong, rich type system
  • A very strict compiler

What about error handling?

Topics

Goals of Error Handling

How Does a Rust Program Crash? What Can We Do About It?

How Does a Rust Program Handle Recoverable Errors?

Interesting Bits & Bobs

Goals of Error Handling

User Feedback:

  1. Tell the user what went wrong
  2. Offer ideas on how to fix it (if they can)

Troubleshooting:

  1. Provide as much info to the operator as possible
  2. Make debugging simple so the issue can be rectified quickly

Unrecoverable Errors :: panic

  • When something that shouldn't happen happens, and is better to terminate execution than allowing UB to occur

  • An unhandled panic causes the thread it occurred on to terminate

    • If it's the main thread, it'll terminate the program with a 101 exit code
  • An unhandled panic is 99% a bug in the code!

    • CLI should never panic when encountering an error
    • HTTP server should never panic if an error occurred during processing
  • Similar to C++ exceptions: stack is unwound and drop for objects is called (though not guaranteed)

    • Can be turned off for increased performance

How Can We panic? :: Macros

panic!("I'm panicking!");
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// I'm panicking!

let res = if n < 5 { foo(n) } else { todo!("Will do next week") };
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// not yet implemented: Will do next week

let res = if n < 5 { foo(n) } else { unimplemented!("Not needed for now") };
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// not implemented: Not needed for now

match n /*: Option<i32> */ {
  Some(n) if n >= 0 => println!("Some(Non-negative)"),
  Some(n) if n <  0 => println!("Some(Negative)"),
  Some(_)           => unreachable!("Handled all ns already"), // <-- compile error if not here
  None              => println!("None"),
}
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// internal error: entered unreachable code: Handled all ns already

How Can We panic? :: Functions

let home_dir = home::home_dir().unwrap(); // home::home_dir() -> Option<PathBuf>
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// called `Option::unwrap()` on a `None` value

let contents = read_to_string("a.txt").unwrap(); // read_to_string(P) -> io::Result<String>
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// called `Result::unwrap()` on an `Err` value:
//   Os { code: 2, kind: NotFound, message: "No such file or directory" }

let home_dir = home::home_dir().expect("User should have a home directory");
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// User should have a home directory

let contents = read_to_string("a.txt").expect("File should exist");
// $ cargo run
// thread 'main' panicked at src/main.rs:2:3:
// File should exist:
//   Os { code: 2, kind: NotFound, message: "No such file or directory" }

When to panic In Our Code?

  • When we want to halt execution because something we expect does not hold
  • In test code, when we don't care about the error case (panic → test failure)
assert_eq!(3, 4); // assertion `left == right` failed, left: 3, right: 4
assert!(3 == 4); // assertion failed: 3 == 4

Always document whether a function can panic:

/// # Panics
///
/// Will panic if `y` is `0`.
pub fn divide(x: i32, y: i32) -> i32 {
  if y == 0 { panic!("Cannot divide by 0") } else { x / y }
}

What panics in std?

std documentation always states whether a function can panic, for example:

  • Vec<T>::[i]/HashMap<K, V>::[key] - Unchecked index operator
    • get/get_mut - Checked version, returns Option<&T>/Option<&mut T>
fn main() { let _val = Vec::<i32>::new()[1]; }
// $ cargo run
// thread 'main' panicked at src/main.rs:2:33:
// index out of bounds: the len is 0 but the index is 1
  • Integer under/overflow: only in debug builds
#[allow(arithmetic_overflow)] // Needed as the compiler is smart in this case
fn main() { let _n = i32::MAX + i32::MAX; }
// $ cargo run
// thread 'main' panicked at src/main.rs:2:22:
// attempt to add with overflow

Backtraces

By default, panics don't help us in diagnosing how the execution got to that point:

$ cargo run
thread 'main' panicked at src/submodule-1/submodule-2/foo.rs:67842:583:
index out of bounds: the len is 10 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Backtraces

Can enable a backtrace (might have a performance cost):

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/submodule-1/submodule-2/foo.rs:67842:583:
index out of bounds: the len is 10 but the index is 10
stack backtrace:
   0: rust_begin_unwind
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/std/src/panicking.rs:647:5
   1: core::panicking::panic_fmt
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/core/src/panicking.rs:72:14
   2: core::panicking::panic_bounds_check
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/core/src/panicking.rs:208:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/core/src/slice/index.rs:255:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/alloc/src/vec/mod.rs:2771:9
   6: my-project:submodule::submodule-2:my_awesome_function # <-- our function!
             at ./src/subfolder-1/subfolder-2/foo.rs:67842:583
   6: my-project::main # <-- function that called our function!
             at ./src/main.rs:4:26
   7: core::ops::function::FnOnce::call_once
             at /rustc/25ef9e3d85d934b27d9dada2f9dd52b1dc63bb04/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Listening for panics :: panic::set_hook

fn main() {
  std::panic::set_hook(Box::new(|_| {
    println!("A panic occurred!");
  }));
  panic!("Uh oh!");
}
$ cargo run
A panic occurred!

Integration with tracing :: tracing-panic

fn main() {
  // ... `tracing` setup ...
  std::panic::set_hook(Box::new(tracing_panic::panic_hook));

  panic!("I'm panicking!");
}

Produces the following error-level tracing event:

$ cargo run
2024-04-23T19:22:42.351731Z ERROR tracing_panic: A panic occurred
panic payload="I'm panicking!"
panic.location="tracing-panic/src/main.rs:16:5"
panic.backtrace=disabled

Catching panics :: catch_unwind

panics can be caught and turned into recoverable errors:

fn main() {
  let result = std::panic::catch_unwind(|| {
    panic!("oh no!");
  });

  println!("Result: {result:?}");
}
$ cargo run
thread 'main' panicked at src/main.rs:3:5:
oh no!
Result: Err(Any { .. })

Don't try to handle panics in the code, fix the bug which caused them!

Recoverable Errors :: Result<T, E>

Recoverable Errors :: Result<T, E>

The trick to it is - there is no trick! Just leveraging tagged unions:

enum Result<T, E> {
  Ok(T),
  Err(E),
}

Similar to:

  • Java's checked exeptions (try/catch, throw/throws) - but no special control flow
  • Haskell's Either a b = Left a | Right b type - but in an imperative environment
  • Go's tuple-based approach (func F() (int32, error)) - but it cannot be misused

Recoverable Errors :: Result<T, E>

fn main() {
  println!("Start!");
  match std::fs::read_to_string("file.txt") {
    Ok(contents) => println!("{contents}"),
    Err(e) => println!("Failed to read file, cause: {e}"),
  }
  println!("Done!");
}
$ cargo run
Start!
Failed to read file, cause: No such file or directory (os error 2)
Done!

Returning Errors - What Should E Be?

Start out with a function:

fn parse_value_from_file(path: AsRef<Path>) -> Value;

But this can fail!

fn parse_value_from_file(path: AsRef<Path>) -> Result<Value, ???>;

Returning Errors - What Should E Be?

"I'll think about error handling later":

fn parse_value_from_file(path: AsRef<Path>) -> Result<Value, String> {
  ...
  if file_does_not_exist {
    return Err(format!("File '{path}' does not exist"));
  }
  ...
  if contents_malformed {
    return Err("Contents of file were malformed".to_string());
  }

  ...

  Ok(parsed_contents)
}

But: Not convenient to work with programmatically!

Returning Errors - What Should E Be?

Decision point: Do I want the caller to know about different failure modes?

If so → Provide an enumerated error type

Else → Provide an opaque error type

Enumerated Errors

Enum, where each variant describes a particular failure mode.

Variants can contain extra info on what went wrong:

enum ParseValueError {
  FileDoesNotExistError(PathBuf),
  MalformedInputError { line: u64, col: u64 },
}

Even better, using an other error as a source:

enum ParseValueError {
  IoError(std::io::Error),
  MalformedInputError { line: u64, col: u64 },
}

No trick, just leverage the type system!

What Makes an Error a Good Error:

  1. Offers description to the end user → Implements Display
  2. Offers description to the operator → Implements Debug
  3. Can be composed with other errors → Implements Error

Implement Display

Provide a textual representation of what went wrong to the end user. Enables:

  • Converting the error into a String:
let str = e.to_string();
  • Printing the error using the {} placeholder in format strings
println!("Something went wrong: {e}");
impl std::fmt::Display for ParseValueError {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    match self {
      Self::IoError(_) => write!(f, "There was a problem when reading the input"),
      Self::MalformedInputError { line, col } =>
        write!(f, "The input is malformed on {line}:{col}"),
    }
  }
}

Implement Debug

Provide a textual representation of what went wrong to the operator. Enables:

  • Printing the error using the {:?} placeholder in format strings
println!("Something went wrong: {e:?}");
#[derive(Debug)]
enum ParseValueError {
  ...
}
let err = ParseValueError::MalformedInputError { line: 42, col: 100  };
println!("{err:?}");
$ cargo run
MalformedInputError { line: 42, col: 100 }

Implement Error

Mark an error type as an std::error::Error, so callers can refer to it using dyn pointers and references - can work with the error without knowing its concrete type.

trait Error: Debug + Display { ... }
impl std::error::Error for ParseValueError {}

All error types should implement it, to make them composable with other errors!

std::error::Error::source

Can provide a source to capture the cause of the error:

impl std::error::Error for ParseValueError {
  fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
    match self {
      Self::IoError(e) => Some(e),
      Self::MalformedInputError { .. } => None,
    }
  }
}

Recap: What Makes a Good Error Type?

  1. Implements Debug - #[derive(Debug)]/impl Debug for ... { ... }
  2. Implements Display - with impl Display for ... { ... }
  3. Implements std::error::Error
  4. Optional: Adds a source implementation to establish a cause chain

This is a lot of boilerplate for each error type we define! How can we do better?

Macros to the Rescue, a.k.a the thiserror Crate

Can help us generate the boilerplate using a procedural derive macro.
But: Increases compile times if we're not using proc macros yet!

// `thiserror::Error` implements `std::error::Error`
#[derive(thiserror::Error, Debug)]
enum ParseValueError {
  #[error("could not open file")] // `Display` impl for this variant
  IoError(
    // `source` = inner error for this variant +
    // `impl From<std::io::Error> for ParseValueError { ... }` +
    // `impl Into<ParseValueError> for std::io::Error { ... }`
    #[from] std::io::Error,
  ),
  #[error("input is malformed at {line}:{col}")] // Supports interpolation
  MalformedInputError { line: u64, col: u64 },
}

A More Advanced Example

#[derive(thiserror::Error, Debug)]
pub enum DataStoreError {
  #[error("data store disconnected")]
  Disconnect(#[from] std::io::Error),

  #[error("the data has been redacted")]
  Redaction(#[source] RedactionError),

  #[error("invalid header (expected {expected:?}, found {found:?})")]
  InvalidHeader { expected: String, found: String },

  #[error(transparent)] // delegate `source` + `Display` to inner error
  Unknown(#[from] Box<dyn std::error::Error>),
}

// How to construct each variant:
let disconnect_error: DataStoreError = some_io_error.into(); // DSE::Disconnect
let redaction_error = DataStoreError::Redaction(RedactionError { ... });
let invalid_header_error = DataStoreError::InvalidHeader { ... };
let unknown_error: DataStoreError = some_opaque_boxed_error.into(); // DSE::Unknown

Let's Try It!

fn save_entity(entity: Entity) -> Result<EntityWithId, DataStoreError> {
  let connection = match db::connect() {
    Ok(c) => c,
    // No need for `DataStoreError::Disconnect(e)`!
    // `std::io::Error` -> `DataStoreError::Disconnect` is handled by `into`
    Err(e) => return Err(e.into()),
  };

  let inserted_entity = match connection.insert(entity) {
    Ok(inserted_entity) => inserted_entity,
    // No need for `DataStoreError::Unknown(Box::new(e))`!
    // `Box<dyn std::error::Error>` -> `DataStoreError::Unknown` is handled by `into`
    Err(e) => return Err((Box::new(e) as Box<dyn std::error::Error>).into()),
  };

  Ok(deserialize_inserted_entity(inserted_entity))
}

Lot of code even for this case - what about more complex functions?

Rust's Superpower: The ? Operator

  • Can use in functions returning Result<T, E> to return early
fn fallible() -> Result<(), std::io::Error> {
  let contents = std::fs::read_to_string("file.txt")?;
  ...
}
  • Performs the following operation: return Err(e.into<E>());
  • The conversion is very convenient, and greatly reduces boilerplate
fn save_entity(entity: Entity) -> Result<EntityWithId, DataStoreError> {
  let connection = db::connect()?; // `std::io::Error` -> `DataStoreError::Disconnect`

  let inserted_entity = connection.insert(entity)
    // `Box<dyn std::error::Error>` -> `DataStoreError::Unknown`
    .map_err(|e| DataStoreError::Unknown(Box::new(e)))?;

  Ok(deserialize_inserted_entity(inserted_entity))
}

Opaque Errors

Sometimes we don't want to let the caller know exactly what went wrong - just let them know something failed. Reasons:

  • There are too many failure modes to keep track of in an enum - burden for caller
  • We don't want to leak implementation details of the function to callers

Simplest way is to use Box<dyn std::error::Error>:

  • Unique pointer to an object that implements std::error::Error
  • Must be behind pointer since trait objects are !Sized - can't place them on stack!
  • Caller can use dynamic dispatch to get:
    • User-facing representation (Display::display/to_string)
    • Operator-facing representation (Debug::debug)
    • The source (Error::source)

Simple Opaque Error Example

The following conversion is implemented by std:

impl From<impl std::error::Error> for Box<dyn std::error::Error> { ... }
fn read_config_file() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
  let contents = std::fs::read_to_string("cfg.json")?;

  let parsed_contents = serde_json::from_str(&contents)?;

  Ok(parsed_contents)
}

Works, but:

  • The error type is quite a mouthful
  • Still need define a custom type if there's no underlying error to propagate
  • Cannot attach extra context to why each error can occur, which could help the user understand what went wrong

Ergonomic Opaque Errors a.k.a the anyhow Crate

Defines anyhow::Error: An opaque error type on steroids

Usually used with its Result shorthand:

type anyhow::Result<T> = Result<T, anyhow::Error>;

Can convert to anyhow::Error from any type implementing std::error::Error:

fn read_config_file() -> anyhow::Result<serde_json::Value> {
  // `impl std::error::Error` -> `anyhow::Error`
  let contents = std::fs::read_to_string("cfg.json")?;

  let parsed_contents = serde_json::from_str(&contents)?;

  Ok(parsed_contents)
}

anyhow::Context

Allows attaching context to errors using the context/with_context extension trait methods:

use anyhow::Context;

fn read_config_file() -> anyhow::Result<serde_json::Value> {
  let contents = std::fs::read_to_string("cfg.json")
    .context("Failed to read config file")?;

  let parsed_contents = serde_json::from_str(&contents)
    .with_context(|| format!("Failed to parse config from {contents}"))?;

  Ok(parsed_contents)
}

anyhow::anyhow!/anyhow::bail!

Allows creating ad-hoc errors without defining a custom error type:

fn validate_password(user_id: &str, password_hash: &str) -> anyhow::Result<()> {
  let password_hash_from_db: String = ...;

  if (password_hash_from_db.len() != password_hash.len()) {
    // `anyhow!` macro for ad-hoc error definition
    return Err(anyhow::anyhow!("Password size doesn't match!"));
  }
  ...

  if (&password_hash_from_db != password_hash) {
    // shorthand: `bail!(...) = return Err(anyhow::anyhow!(...))`
    anyhow::bail!("Passwords don't match!");
  }

  Ok(())
}

anyhow In Action

fn main() {
  let err = read_config_file().context("Failed to initialize config").unwrap_err();
  println!("{res}");
  println!("==========");
  println!("{res:?}");
}
$ cargo run
Failed to initialize config
==========
Failed to initialize config

Caused by:
  0: Failed to read config file
  1: The system cannot find the file specified. (os error 2)


Stack backtrace:
  ...

eyre

Fork of anyhow - adds support for customizable error report Handlers.

Uses its own terminology:

  • anyhow::Error/anyhow::Result -> eyre::Report/eyre::Result
  • Context/context/with_context -> WrapErr/wrap_err/wrap_err_with

But also re-exports anyhow's public types and traits to be a drop-in replacement.

color-eyre

The most widespread Handler used is color-eyre:

Combination of Enumerated + Opaque Errors

For cases where partial differentiation between failure modes is required:

#[derive(thiserror::Error, Debug)]
enum RegisterUserError {
  #[error("{0}")]
  ValidationError(ValidationError),
  #[error(transparent)] // wrap anything unexpected as an `anyhow::Error`
  UnexpectedError(#[from] anyhow::Error),
}

fn validate_user(user_data: &UserData) -> Result<(), ValidationError> { ... }

fn register_user(user_data: UserData) -> Result<(), RegisterUserError> {
  let validated_user = validate_user(user_data)?;
  // Use `.context` to convert underlying error to `anyhow::Error`
  let inserted_user = db::insert(validated_user).context("Failed to insert user")?;
  ...
}

Reporting Errors - With Minimal Noise

Always report errors for operators in a single place.

That place should be where the error is handled, not where it's propagated!

fn fallible() -> anyhow::Result<Value> {
  ...
  let val = foo().map_err(|e| {
    tracing::error!("Error occurred: {e:?}"; // ❌ Reporting + propagation
    e
  }))?;

  let val = foo().context("Failed to perform foo")?; // ✅ Adding extra context
  ...
}

In CLIs

The main function can return Result<(), E> - will print the error's Debug representation to stderr automatically and return a 1 exit code:

  • Just return anyhow::Result<()> (or some other generic error type) from main
  • Use ? to propagate up errors from downstream functions
  • Add a context to each fallible function, so the user knows in what context the error occurred

CLIs :: Example

use anyhow::Context;

fn main() -> anyhow::Result<()> {
  let config_file = read_config_file().context("Failed to initialize config")?;
  let command = parse_command().context("Failed to parse command")?;
  let output = match command {
    Command::Foo => perform_foo(...).context("Failed to execute command foo")?,
    Command::Bar => perform_bar(...).context("Failed to execute command bar")?,
  };

  cleanup(output).context("Failed during cleanup")?;
  Ok(())
}
$ cargo run
Error: Failed to initialize config

Caused by:
  0: Failed to read config file from "./config.toml"
  1: The system cannot find the file specified. (os error 2)

In HTTP Servers

Provide a single error handler that transforms errors to HTTP responses + produces a log message:

"None of the major Rust web frameworks have a great error reporting story, according to my personal definition of great."

  • Every framework does it a bit differently, but the gist is the same:
    • Propagate errors up to the request handler using ?
    • Implement a conversion: YourErrorType -> HttpResponse for defining what should the server return in case of an error
    • Log the error or let the framework do it for you (depending on framework)

HTTP Servers :: actix Example - Server & Endpoint Setup

Use the tracing-actix-web crate that interfaces with tracing for logging!

async fn register(user: Json<UserRequest>) -> Result<HttpResponse, RegisterUserError> {
  let validated_user = validate_user_request(user)?; // <-- Propagate validation error
  let inserted_user = db::insert(validated_user)
    .await
    .context("Failed to insert user in DB")?; // <-- Propagate DB error
  Ok(HttpResponse::Ok().json(inserted_user))
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
  init_tracing();

   HttpServer::new(move || {
    App::new()
      .wrap(TracingLogger::default()) // <-- Add logging middlewre
      .route("/users", web::post().to(register))
    })
  .bind("127.0.0.1:8080")?.run().await?;
  Ok(())
}

HTTP Servers :: actix Example - Error Setup

#[derive(thiserror::Error, Debug)]
enum RegisterUserError {
  #[error("Failed to validate incoming request")]
  ValidationError(ValidationError),
  #[error(transparent)]
  UnexpectedError(#[from] anyhow::Error),
}
impl ResponseError for RegisterUserError {
  fn status_code(&self) -> StatusCode {
    match self { // Define what status code each error variant should map to
      Self::ValidationError(_) => StatusCode::BAD_REQUEST,
      Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
    }
  }
  fn error_response(&self) -> HttpResponse {
    let mut builder = HttpResponse::build(self.status_code());
    match self { // Define the body of each error variant
      Self::ValidationError(v) => builder.json(v),
      Self::UnexpectedError(_) => builder.json(ErrorResponseBody {
        message: "An unexpected error occurred!",
      }),
    }
  }
}

HTTP Servers :: actix Example - Validation Error

Response Code: 400 Bad Request
Response Body:

{
  "message": "User is invalid",
  "fields": [{ "name": "email", "issue": "'foo' does not contain a @" }]
}

Trace:

WARN HTTP request{
  ...
  exception.message=Failed to validate incoming request
  exception.details=ValidationError(ValidationError {
    message: "User is invalid",
    fields: [ValidationErrorField { name: "email", issue: "'foo' does not contain a @"}]
  })
  ...
}

HTTP Servers :: actix Example - Unexpected Error

Response Code: 500 Internal Server Error
Response Body:

{ "message": "An unexpected error occurred!" }

Trace:

ERROR HTTP request{
  ...
  exception.message=Failed to insert user in DB
  exception.details=UnexpectedError(Failed to insert user in DB
    Caused by:
      Could not connect to DB
  )
  ...
}

Bits & Bobs

Result<T, E> Is #[must_use]

Discarding a Result is a compiler warning!

fn fallible() -> anyhow::Result<()> { ... }

fn main() {
  // warning: unused `Result` that must be used
  // = note: this `Result` may be an `Err` variant, which should be handled
  fallible();
}

Solutions:

  • Propagate: fallible()?;
  • Unwrap with note: fallible().expect("fallible should never fail as ...");
  • Explicitly discard: let _ = fallible();
let _ = tx.send(42); // `tx` is the Sender side of a channel

Result<Result<T, E1>, E2> :: Double Trouble

Useful if we want to force separate handling of different errors:

fn read_config_file() -> Result<Result<serde_json::Value, serde_json::Error>, std::io::Error> {
  let contents = std::fs::read_to_string("cfg.json")?;

  let parse_result = serde_json::from_str(&contents);

  Ok(parse_result)
}

fn main() -> anyhow::Result<()> {
  let value = read_config_file()??; // Don't differentiate between errors

  let value = read_config_file()
    .context("Failed to read config file")? // Add context for outer error
    .context("Failed to parse config file")?; // Add context for inner eror

  let value = read_config_file()
    .unwrap_or(Ok(serde_json::Value::Null))?; // handle outer error differently from inner

  Ok(())
}

Keep in Mind the Error Size

Results should not have large overhead - if the Error type is large, better to Box it!

A good example of this practice is serde_json::Error:

pub struct Error {
  err: Box<ErrorImpl>,
}

struct ErrorImpl {
  code: ErrorCode,
  line: usize,
  column: usize,
}

pub(crate) enum ErrorCode {
  Message(Box<str>),
  Io(io::Error),
  EofWhileParsingList,
  ...
}

The Future

try Blocks

Create a block where you can use ? without creating a new function:

#![feature(try_blocks)]
fn foo(num_str_1: &str, num_str_2: &str) -> i32 {

  let result: Result<i32, std::num::ParseIntError> = try {
      num_str_1.parse::<i32>()? + num_str_2.parse::<i32>()?
  };

  result.unwrap_or(42)
}

Tracking issue: https://github.com/rust-lang/rust/issues/31436

std::error::Error::sources

Get an iterator that traverses all sources using Error::source:

#![feature(error_iter)]
impl std::fmt::Debug for ParseValueError {

  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    writeln!(f, "{}", self)?;

    for source in self.sources().skip(1) {
      writeln!(f, "Caused by:\n\t{source}")?;
    }

    Ok(())
  }
}

Tracking issue: https://github.com/rust-lang/rust/issues/58520

Homework

This was just an introduction, there is still so much to dive into!
Here are some recommendations:

References

Docs: std, tracing-panic, thiserror, anyhow, eyre

try_blocks chapter from The Rust Unstable Book

matklad (2020). Study of std::io::Error

Palmieri, Luca (2024). Rust web frameworks have subpar error reporting

Gjgengset, Jon (2022). Rust for Rustaceans. No Starch Press.

Palmieri, Luca (2022). Zero to Production in Rust.

Summary & Questions

  • panic == bug in our code
  • Result<T, E> for recoverable errors: enumerated vs. opaque
  • ? = return Err(e.into<E>())
  • Single location where the error:
    • Gets logged
    • Is converted to a user-facing representation

Levente Krizsán - klevente.dev