written on Thursday, November 6, 2014
Rust just got a huge update: it landed multidispatch and the FromError trait. The combination of both of those things improves error handling in Rust significantly. The change is in fact so significant that you can expect many APIs to change greatly in the upcoming weeks.
While error handling in Rust is definitely not a closed case, I assume that the paradigm enabled through these changes is going to hang around for a long, long time.
This article is going to give a quick introduction in how error handling works from this moment forward and why.
I have written about this problem a bit before but in essence the core issue with error handling is that possible errors become part of your API contract. This means that if you modify the implementation of your function, any new error case you introduce, modifies your contract to your caller ever so slightly. This is especially an issue as you refactor your function to call into a new range of functions that can fail with a whole new set of errors.
The problem is to find a middle ground between extensibility and API stability. As an API developer you want to be able to be future proof in the sense that you can change your implementation and call into entire new libraries that you did not envision in the past. For instance your cache layer of choice might so far only have failed with general IO errors but now you change your implementation to use a new library which all the sudden wants to fail with redis errors. While you could just hide those errors and report them under a new name, it's really more interesting to the user to also expose the original root cause of the error.
And whatever you do, you do not want to negatively affect your existing users.
It's not just evolving APIs though. If you have a function that can fail with two different errors, you want to have to write as little boilerplate as necessary to handle the errors from different origins.
To be a bit more concrete: imagine you build a command line application that does HTTP requests to the internet. Such an application will have to deal with a whole range of failures: command line argument parsing errors, IO errors, HTTP failures, SSL failures etc. Some of those errors you want to be able to act on (for instance to convert HTTP status codes for timeouts from an error into an implicit retry), others you want to bubble up until you eventually show them to the user. And all of that with as little code as possible.
Rust's solution for this problem is the FromError trait. The idea is simple but clever. The FromError trait provides a standardized conversion of errors into another error. This conversion however for the most part will not actually "convert" the error but "wrap" it. There is no requirement for this however, you have all the freedom you need. If you want you can completely hide the original error. In addition to that it does not actually restrict it to other errors, so you can convert quite freely between different things. If you want you can convert error codes into fully fleshed out error structs through the same machinery.
Errors in Rust are as of now, supposed to implement this trait:
pub trait Error: Send {
fn description(&self) -> &str;
fn detail(&self) -> Option<String> { ... }
fn cause(&self) -> Option<&Error> { ... }
}
This is not a lot of information, and it's quite likely that this trait will grow in the future, but for the moment it holds at least some information that we can show error messages. description returns a string slice to a general description that is available whereas detail holds optional information that can provide more details. The latter is considered to be information that might be required to compute.
The third trait method is cause which allows an error to point to another error which was the cause. For instance if you have a library that does HTTP calls, and one API method fails on the IO layer your error could point to the originating IoError as cause.
The actual conversion between errors is kicked off by the FromError trait which you need to implement for all compatible errors. For instance for the HTTP library example it could look like this:
use std::{io, error};
enum LibError {
BadStatusCode(int),
IoError(io::IoError),
}
impl FromError<io::IoError> for LibError {
fn from_error(err: io::IoError) -> LibError {
IoError(err)
}
}
In this case we have our own error type LibError which has two possible failures: A BadStatusCode which is a failure that clearly indicates a problem in our own library as well as IoError which wraps another error from another library (in this case the IO system in Rust). Through the FromError trait we now get a standardized conversion from IoError to LibError from our own library. This functionality is provided through multidispatch. All this dispatching however is done at compile time.
At this point we can also to implement the actual error trait to make our error useful. It looks like this:
impl error::Error for LibError {
fn description(&self) -> &str {
match *self {
BadStatusCode => "bad status code",
IoError(err) => "encountered an I/O error",
}
}
fn detail(&self) -> Option<String> {
match *self {
BadStatusCode(code) => Some(format!("status code was {}", code)),
_ => None,
}
}
fn cause(&self) -> Option<&error::Error> {
match *self {
IoError(ref err) => Some(err as &error::Error),
_ => None,
}
}
}
How exactly your error however looks is entirely up to you. For instance you could convert certain IoErrors into errors of your type entirely. For instance one could imagine converting an FileNotFound IO error into something else.
Now at this point you will be asking: what actually uses FromError? The answer is that right now in Rust there is only a single place where this is being used and that's in the try! macro. It looks like this:
macro_rules! try (
($expr:expr) => ({
match $expr {
Ok(val) => val,
Err(err) => return Err(::std::error::FromError::from_error(err))
}
})
)
As you can see it implements processing of a Result value. If the value is Ok it will return the wrapped value, otherwise it will issue an early return where the conversion goes through FromError. How does it know which error it should convert to? From the signature of the function it's being used in:
fn make_request(method: &str, url: &str) -> Result<Vec<u8>, LibError> {
let (host, port, path) = parse_url(url);
let socket = try!(open_socket(host, port));
let req = try!(make_request(method, host, port, path));
try!(socket.write(req));
Ok(try!(socket.recv()))
}
This obviously is a bit of pseudocode but you get the idea. Because the function returns a Result with LibError it will invoke the FromError conversion to LibError which is the type in our own library. For as long as all the try! macros go to compatible errors for which we defined a conversion this code will work. In this case try! macro can have code that either fails with IoError or LibError itself (each error implicitly noop converts to itself through a default generic implementation).
There is a second macro I started using which I called fail! for aborting with an error:
macro_rules! fail {
($expr:expr) => (
return Err(::std::error::FromError::from_error($expr));
)
}
It's basically just the error part of the try! macro but it's very useful because it goes through the FromError machinery. This allows you to fail! with any compatible error.
The FromError trait however also has another nice benefit. Because it can work with arbitrary types and not just types implementing Error you can build convenience methods to create errors. In redis-rs for instance I implemented FromError for tuples that create the most common errors:
impl error::FromError<(ErrorKind, &'static str)> for RedisError {
fn from_error((kind, desc): (ErrorKind, &'static str)) -> RedisError {
RedisError { kind: kind, desc: desc, detail: None }
}
}
With this I can now write code like that:
fn connect_to_url(url: Url) -> RedisResult<Connection> {
if url.scheme[] != "redis" {
fail!((InvalidClientConfig, "URL provided is not a redis URL"));
}
Ok(try!(open_connection(url.host, url.port)))
}
In the absence of that machinery I would have to write something like this:
fn connect_to_url(url: Url) -> RedisResult<Connection> {
if url.scheme[] != "redis" {
return Err(RedisError {
kind: InvalidClientConfig,
desc: "URL provided is not a redis URL",
detail: None,
});
}
match open_connection(url.host, url.port) {
IoError(err) => {
Err(RedisError {
kind: InternalIoError(err),
desc: "An internal IO error ocurred.",
detail: None
}),
Ok(con) => Ok(con),
}
}
}
As you can see from the last example, the error type in Redis is a struct. That also goes for the IoError which is a struct of similar layout. But you could make your error as small as an enum if you would want. So what are best practices for designing your errors?
The fastest but most inflexible error representation is an enum of simple individual fields. In that case the original cause gets lost because we never store it. However this can be fine in cases where the possibly encountered errors are of such a small subset that it's okay to lose a bit of information. This pattern is especially useful in places where errors can happen very regularly:
enum LibError {
EntryMissing,
BadFileFormat,
InternalError,
CouldNotOpenFile,
}
impl error::Error for LibError {
fn description(&self) -> &str {
match *self {
EntryMissing => "entry is missing",
BadFileFormat => "a bad file format encountered",
CouldNotOpenFile => "unable to open file",
InternalError => "an internal error occurred",
}
}
}
impl FromError<io::IoError> for LibError {
fn from_error(err: io::IoError) -> LibError {
match err {
{ kind: io::FileNotFound, .. } => CouldNotOpenFile,
{ kind: io::PermissionDenied, .. } => CouldNotOpenFile,
_ => InternalError,
}
}
}
In that case the only description of the error you can report is a static string for each of those values and we do not have a cause. For some IO errors we can produce different error codes and for anything else that comes up we will produce a fallback internal error. If you see those creeping up you might want to make your LibError larger.
Similar to the simplified enums example you can take it a bit further and keep using simple enum values for your own errors but wrap external errors entirely. This allows you to keep the original cause around:
enum LibError {
EntryMissing,
BadFileFormat,
IoError(io::IoError),
}
impl error::Error for LibError {
fn description(&self) -> &str {
match *self {
EntryMissing => "entry is missing",
BadFileFormat => "a bad file format encountered",
IoError(_) => "an I/O error occurred",
}
}
fn cause(&self) -> Option<&error::Error> {
match *self {
IoError(ref err) => Some(err as &error::Error),
_ => None,
}
}
}
impl FromError<io::IoError> for LibError {
fn from_error(err: io::IoError) -> LibError {
IoError(err)
}
}
In this case our own internal errors are still a bit light on the error reporting but for any IO error we have the full original cause hanging around.
Struct-like enum variants are a feature that until recently were behind a feature gate and as such not commonly used because they were too much of a hassle. The idea is that a enum field can look like a struct, not just like an enum. This is especially useful when dealing with errors because depending on which error you report, different fields might be relevant. For instance we can take the above example and add extra error information where available:
enum LibError {
EntryMissing { name: String },
BadFileFormat,
IoError(io::IoError),
}
impl error::Error for LibError {
fn description(&self) -> &str {
match *self {
EntryMissing { .. } => "entry is missing",
BadFileFormat => "a bad file format encountered",
IoError(_) => "an I/O error occurred",
}
}
fn detail(&self) -> Option<String> {
match *self {
EntryMissing { name: n } => Some(format!("Name of entry: {}", n)),
_ => {}
}
}
fn cause(&self) -> Option<&error::Error> {
match self {
&IoError(ref err) => Some(&*err as &error::Error),
_ => {},
}
}
}
impl FromError<io::IoError> for LibError {
fn from_error(err: io::IoError) -> LibError {
IoError(err)
}
}
Because you can do pattern matching on those struct enum variants a user can trivially extract the information they need if they want to act on it. For generic error reporting we can use that information ourselves to implement the detail method and generate a better error message from extra information available.
If you expect your errors to contain a lot of important information beyond just an error code it's a good idea to investigate using a struct. How you lay out that struct is up to you. The following example is how I set it up in redis-rs. This pattern makes sense if you expect errors to be infrequent but carry a lot of information when they happen:
enum ErrorKind {
EntryMissing,
BadFileFormat,
IoError(io::IoError),
}
struct LibError {
pub kind: ErrorKind,
pub detail: Option<String>,
}
impl error::Error for LibError {
fn description(&self) -> &str {
match *self.kind {
EntryMissing => "entry is missing",
BadFileFormat => "a bad file format encountered",
IoError(_) => "an I/O error occurred",
}
}
fn detail(&self) -> Option<String> {
self.detail.clone(),
}
fn cause(&self) -> Option<&error::Error> {
self.cause.as_ref()
}
}
impl FromError<io::IoError> for LibError {
fn from_error(err: io::IoError) -> LibError {
LibError {
kind: IoError(err),
detail: None,
}
}
}
It's a good idea to always have an error kind enum so that users of your library can do pattern matching on your errors to figure out what exactly went wrong. This is crucial if you want to allow recovery of a problem. For instance code might want to act upon an EntryMissing error but not on any other error.
At the moment it looks like what we have currently is pretty much what we will be stuck with until after 1.0. The Carrier trait that would allow options and results to be handled similarly will most likely not land, same for the syntax support. However it's quite likely that we will see some experimentation in external crates and with custom macros to see where error handling could go from here.
To be a good citizen in the Rust world I changed all my example code in the redis-rs documentation to use try! instead of unwrap and it looks pretty good.
I do have some hopes that fail! will make it into the stdlib but it's so easy to write yourself that I expect that macro to pop up in many places. Not having it in the language will not be the end of the world.