Error handling in Rust web frameworks
The two most popular Rust web frameworks, Rocket and Actix Web both let you define request handlers like the following:
#[post("/echo", data="<msg>")]
fn echo(msg: Json<Message>) -> String {
msg.text
}
Notice how the parameter is automatically derived from the request: we just
declare the parameter and the framework takes care of passing the argument.
In the above example the framework will parse the request body as JSON and
pass the deserialized Message
struct. How does this work?
-
the framework makes types like
Json
derivable from the request by implementing a specific trait (e.g. rocket::request::FromRequest, rocket::data::FromData or actix_web::FromRequest) -
the routing macro (e.g.
#[post]
) calls the method defined by this trait to derive the value
While the above request handler doesn’t look like it, the deserialization can
fail. The error handling is hidden behind the macro. Rocket and Actix however
also support explicit error handling by wrapping the parameter in a
Result, e.g. declaring the above
parameter as Result<Json<Message>, JsonError>
.
Rocket #
If Rocket fails to deserialize a request body it returns a rather unhelpful error message:
422: Unprocessable Entity #
The request was well-formed but was unable to be followed due to semantic errors.
Rocket uses Serde for deserialization and Serde does provide descriptive error messages, so how come Rocket does not forward these descriptive error messages to the user?
As it turns out this is because of a striking limitation in Rocket’s API.
Rocket’s error catchers
are invoked only based on an error’s status code and do not have access to the actual error.
Rocket’s plan to introduce typed error catchers fell flat because the non_static_type_id
Rust feature was rejected.
Let’s take a step back to look at error handling in Rust in general. To
implement central error handling in your application, you will want to define
your own error enum.
The question mark operator makes short-circuiting errors convenient.
Since the ? operator passes errors through From::from
, you don’t
even need to clutter your code with Result::map_err
calls, you can just
define From
conversions to your own error type.
While implementing your own error type and From conversions can be a
hassle, the thiserror crate makes it
easy:
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("page not found")]
NotFound(String),
#[error("{0}")]
JsonError(#[from] JsonError)
}
#[post("/echo", data="<msg>")]
fn echo(msg: Result<Json<Message>, JsonError>) -> Result<String, Error> {
msg?.text
}
Unfortunately this doesn’t work with Rocket’s error types because e.g.
JsonError
and FormError
have a non-static lifetime, which isn’t supported by thiserror
’s #[from]
attribute
because Error::source
always needs an inner error that is 'static
.
Another design decision of Rocket that leads to unhelpful client error messages is its query-sensitive routing. Let’s take the following request handler as an example:
#[get("/sum?<a>&<b>")]
fn sum(a: u8, b: u8) -> String {
format!("{}", a + b)
}
If you request /sum
, omitting the required parameters, you get a generic
404. To provide a helpful error message you have to define a fallback handler:
#[get("/sum")]
fn sum_usage() -> BadRequest<&'static str> {
BadRequest("usage: /sum?a=<num>&b=<num>")
}
A handwritten error message does however come with the disadvantage that we need to update it whenever we change the parameters.
Actix #
While Actix does provide helpful deserialization error messages by default, it provides its own general purpose error type, which is misguided for three reasons:
-
Since Rust doesn’t let you implement a foreign trait for a foreign type, you cannot implement a From conversion between e.g. your database errors and the Actix Error, forcing you to use
map_err
everytime you want to short-circuit a database error. -
The Actix Error type just wraps an HTTP responder, disallowing a meaningful central error handler. While Actix does let you customize the error presentation, doing so is akward since you need to register error-type specific error handlers and handle non-actix errors in yet another way.
-
Actix implements
From<std::io::Error>
for its custom error type, making it dangerously easy to leak IO errors to clients.
While these points can be remedied by implementing your own error type, since Actix for some reason eagerly wraps all of its errors in its general purpose error type, you won’t be able to completely avoid it, if you want to provide helpful client error messages. Last but not least Actix’s FromRequest trait is less flexible than Rocket’s, because it doesn’t let you set cookies without aborting the request, which would for example be handy for cookie-based CSRF tokens.
Launching Sputnik #
After being fed up with the limitations of Rocket and Actix, I tried using Hyper directly (both Rocket and Actix are based on Hyper). Hyper provides a great foundation, it however does not provide the convenience of higher-level web frameworks. For example its types provide no methods to work with cookies, content-types or redirects. Well what do you do when you miss methods for types in Rust? You implement them with traits. And I did just that and created a new library: Sputnik.
With Sputnik the initial example becomes:
async fn echo(req: &mut Parts, body: Body) -> Result<Response, Error> {
let msg: Message = body.into_json().await?;
Ok(Response::new(msg.text.into()))
}
While Sputnik requires you to define your own error type, this puts you in
full control over all aspects of your error handling (presentation & logging).
Since Sputnik restricts its error types with 'static
you can easily generate
From
conversions for your own error type with
thiserror.
Sputnik lets you centrally address the cross-cutting concern that is error handling,
without rocket science or balancing acts.
For more information check out Sputnik on crates.io.