Cute Rust trick for error traces during prototyping

Did you ever run into the situation where a short program you just started developing failed? And the error was something generic like:

No such file or directory

You may be left in the same state of confusion as me. What did I mess up? And where did that happen? I'll demonstrate my wants on a small example:

#[derive(Debug)]
struct OurError(std::io::Error);

fn main() -> Result<(), OurError> {
  some_operation()?;
  Ok(())
}

fn some_operation() -> Result<(), OurError> {
  let _ = std::fs::read("/tmp/does_not_exist")?;
  // ERR: file: "src/main.rs", line: 10, col: 11
  // ERR: Os { code: 2, kind: NotFound, message: "No such file or directory" }
  Ok(())
}

The comments are what I would like to be shown. That's where I've misused the standard library, not expecting that reads of non-existent files will fail (in practice this is more likely to be something like a configuration file). Rust's type system has already made it obvious but maybe I'd thought this to be a fringe case, not common. That's exactly the kind of misconception we discover during prototyping.

Luckily, Rust offers a really simple way to introduce some tracing—without forcing us to change any of the call locations!

Try adding caller locations

We'll make use of the fact that the ?-operator (try) will inject a call to From::from for conversion of the error variant. For this call we can also automatically get the caller location. Note that the following is not recommended for production if you're ever concerned about data leaks. It will add a string with the location to the final executable.

use core::panic::Location;

// this is new  v------------------------v
struct OurError(&'static Location<'static>, std::io::Error);

impl From<std::io::Error> for OurError {
  // This attribute is required.
  #[track_caller]
  fn from(err: std::io::Error) -> OurError {
    // Magically get the call location inserted!
    OurError(Location::caller(), err)
  }
}

Further possibilities

This is great, but also it is 'forward compatible' with many, more robust, error patterns. For instance, you can combine it with explicit conversion methods and incrementally transition to map_err(OurError::from_library_x). You could execute some code with the location, such as attaching it as an anyhow::Error::context. You could create an extra error types for some internal module and then store an error location at each module boundary—yielding something like a logical backtrace.

Conclusion

Rust's error handling can be both terse and insightful. Combining the tools provided by the language allows us to build customized abstractions with minimal impact on usage sites. And we can leverage them for 'cheap wins' during prototyping.

Published on
Last updated