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 {
.text
msg}
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")]
String),
NotFound(#[error("{0}")]
#[from] JsonError)
JsonError(}
#[post("/echo", data="<msg>")]
fn echo(msg: Result<Json<Message>, JsonError>) -> Result<String, Error> {
?.text
msg}
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> {
"usage: /sum?a=<num>&b=<num>")
BadRequest(}
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.