2017-05-10
I have been using Servant for about two years now. I really like it. It's easy to use and type-safe. If I need to write a JSON API in Haskell, Servant is almost always my first choice.
However, one thing I have always wanted is an easy way to handle errors in my server handlers. The ideal solution would have the following features:
- Specify not only success responses, but also error responses in the Servant API type.
- Easily reuse the same error type in different APIs. For instance, one route could return a
FooError
, and another route could return either aFooError
orBarError
. - Easily write documentation for the potential errors using
servant-docs
. - Make sure the
servant-server
handlers can only throw the errors specified in the API type. Everything should be completely type-safe. - In the
servant-server
handlers, return different HTTP status codes for each different error. - In the
servant-server
handlers, have the possibility of throwing errors from a short-circuiting monad (e.g.ExceptT
).
Over the last few weeks, I have written a library which provides some of these features: servant-checked-exceptions
.
This blog post first explains how to use servant-checked-exceptions
. It then shows how well servant-checked-exceptions
meets each of these features.
How to use servant-checked-exceptions
servant-checked-exceptions
provides a new API combinator to specify which errors are thrown by a route: Throws
.
Here is an example of using Throws
to define a route:
type Api =
"post" :>
Capture "post-id" PostId :>
Throws DatabaseError :>
Throws BlogPostNotFoundError :>
Get '[JSON] BlogPost
This route returns a BlogPost
based on a PostId
. The server handler for this route must connect to the database to retrieve the BlogPost
, so there are two possible errors that might be returned: DatabaseError
and BlogPostNotFoundError
.
DatabaseError
and BlogPostNotFoundError
are defined like this:
The server handler for this route will look like the following:
getBlogPost
:: PostId
-> Handler (Envelope '[DatabaseError, BlogPostNotFoundError] BlogPost)
getBlogPost postId = do
maybeDbConn <- connectToDatabase
case maybeDbConn of
Nothing -> pure $ toErrEnvelope DatabaseError
Just dbConn -> do
maybeBlogPost <- lookupBlogPost dbConn postId
case maybeBlogPost of
Nothing -> pure $ toErrEnvelope BlogPostNotFoundError
Just blogPost -> pure $ toSuccEnvelope blogPost
(For completeness, I've added a footnote with possible types for connectToDatabase
and lookupBlogPost
1.)
If you look at the signature of getBlogPost
, you'll see that it is returning an Envelope
. An Envelope
is a sum type. It is a wrapper around a single success type (SuccEnvelope
), or multiple possible error types (ErrEnvelope
). In this getBlogPost
example, the success type is a BlogPost
, and the possible error types are DatabaseError
and BlogPostNotFoundError
.
How can Envelope
contain an arbitrary number of possible error types? Internally, it is using an OpenUnion
. This is an extensible sum type2. However, you do not need to fully understand how this works in order to use Envelope
.
Let's look through the getBlogPost
code. First, connectToDatabase
is called to get a connection to the database. If this fails, toErrEnvelope
is used to create an Envelope
holding a DatabaseError
. If connectToDatabase
succeeds, the database connection (dbConn
) is passed to lookupBlogPost
to try to lookup a blog post. If this fails, toErrEnvelope
is used again to return a BlogPostNotFoundError
error. If lookupBlogPost
succeeds, the resulting blog post is wrapped in an Envelope
with toSuccEnvelope
and returned.
If you would like to see more in-depth documentation, you an check out the servant-checked-exceptions
documentation on Hackage. There is also a longer example in the repository on Github. The README.md
shows how to compile and run the example.
Features
In the beginning of this blog post I listed the ideal features for an error library for Servant. Let's see which features servant-checked-exceptions
gives us.
Specify not only success responses, but also error responses in the Servant API type.
Using the Throws
API combinator, it is possible to specify exactly which error responses a route can return.
Easily reuse the same error type in different APIs. For instance, one route could return a
FooError
, and another route could return either aFooError
orBarError
.
Using the Throws
API combinator, it is possible to reuse the same errors for different routes.
Easily write documentation for the potential errors using
servant-docs
.
Writing documentation is very easy. All you need to do is create a ToSample
instance for each error specified with Throws
. Here is an example.
Make sure the
servant-server
handlers can only throw the errors specified in the API type. Everything should be completely type-safe.
This is achieved with a combination of Throws
and Envelope
.
In the
servant-server
handlers, return different HTTP status codes for each different error.
This is currently not possible. It may be possible in the future if Servant changes how errors are handled. More information can be found in this issue on Github3.
In the
servant-server
handlers, have the possibility of throwing errors from a short-circuiting monad (e.g.ExceptT
).
This is currently not implemented. However, it would be neat to figure out a way to get this working. If you have any good ideas for this, please comment in this issue on Github.
Future Work
Aside from the two features not yet implemented above, there are two other open issues that would be nice to solve:
ToJSON
/FromJSON
instances forEnvelope
Right now, the end-user doesn't have any control over the
ToJSON
andFromJSON
instances for theEnvelope
type. Some users may want to JSON-encodeEnvelope
differently. It would be nice to give the end user a way to do this.If you're interested in this, checkout this issue on Github.
Get rid of the
lens
dependency.It would be nice to not depend on
lens
. This should be relatively easy do. If you are interested in working on this, see this issue on Github.
Conclusion
servant-checked-exceptions
isn't perfect, but it is a big improvement over other current methods. With future improvements in Servant, servant-checked-exceptions
may become even easier to use. If you care about producing readable documentation with servant-docs
, specifying errors in the API type is a necessity.
Appendix: Other ways of specifying errors in a Servant API
If you are not using servant-checked-exceptions
, there are two main ways of handling errors in a Servant API. The first is to use a different sum type for each handler. The sum type specifies which errors a route can return. The second way is to use a short-circuiting monad to throw errors.
Responses in a sum type
Let's take a look at the example from the beginning of this article. Rewriting this using a sum type for a response might look like this:
type ApiSumType =
"post" :>
Capture "post-id" PostId :>
Get '[JSON] GetBlogPostResp
data GetBlogPostResp
= GetBlogPostDatabaseError DatabaseError
| GetBlogPostNotFoundError BlogPostNotFoundError
| GetBlogPostSuccess BlogPost
GetBlogPostResp
is a sum type wrapping around either a DatabaseError
, BlogPostNotFoundError
, or BlogPost
.
The server handler might look like this:
getBlogPostSumType :: PostId -> Handler GetBlogPostResp
getBlogPostSumType postId = do
maybeDbConn <- connectToDatabase
case maybeDbConn of
Nothing -> pure $ GetBlogPostDatabaseError DatabaseError
Just dbConn -> do
maybeBlogPost <- lookupBlogPost dbConn postId
case maybeBlogPost of
Nothing -> pure $ GetBlogPostNotFoundError BlogPostNotFoundError
Just blogPost -> pure $ GetBlogPostSuccess blogPost
getBlogPostSumType
is pretty similar to getBlogPost
, but we have to manually wrap the error and success types.
Pros
- This is just as type-safe as the method presented in
servant-checked-exceptions
. - Easy to understand.
Cons
- This is more verbose than the method presented in
servant-checked-exceptions
. Separate sum types have to be created for every different route. - Each response sum type has to have separate
ToJSON
andFromJSON
instances defined for it. It may be possible to automate this somewhat with generic programming techniques (or even Template Haskell). - Each response sum type has to have separate
ToSample
instances defined for it. This can be quite tedious.
Short-circuiting Monad
Using a short-circuiting monad for the server handlers is also an easy method.
Note that the potential errors don't even show up in the ApiShortCircuit
definition.
The server handler might look like this:
getBlogPostShortCircuit
:: (MonadError AppError m, MonadIO m)
=> PostId -> m BlogPost
getBlogPostShortCircuit postId = do
maybeDbConn <- connectToDatabase
case maybeDbConn of
Nothing -> throwError DatabaseError
Just dbConn -> do
maybeBlogPost <- lookupBlogPost dbConn postId
case maybeBlogPost of
Nothing -> throwError BlogPostNotFoundError
Just blogPost -> pure blogPost
data AppError
= BlogPostNotFoundError
| DatabaseError
| SomeOtherAppError
| AuthorNotFoundError
| ...
AppError
is a giant sum type that specifies every possible error for our API. You will also need a function that converts AppError
into ServantErr
.
Pros
- This is relatively easy to use.
Cons
- It's not possible to throw different sets of errors from different server handlers.
MonadError
won't compose if the error type is different. That is to say, it's not possible to composeMonadError e m
withMonadError e' m
. - The potential errors are not present in the API type. The errors won't be present when generating documentation with
servant-docs
. - It is possible to do something like throwing an
AuthorNotFoundError
fromgetBlogPostShortCircuit
. Semantically, this doesn't make any sense.
Footnotes
Here are type signatures for
connectToDatabase
andlookupBlogPost
:↩︎-- | Connect to the database and return a 'DbConn'. This 'DbConn' -- can be used in a call to 'lookupBlogPost'. If the database -- cannot be accessed, return 'Nothing'. connectToDatabase :: MonadIO m => m (Maybe DbConn) connectToDatabase = ... -- | Lookup a 'BlogPost' based on a 'PostId'. If the blog post -- cannot be found, return 'Nothing'. lookupBlogPost :: MonadIO m => DbConn -> PostId -> m (Maybe BlogPost) lookupBlogPost dbConn postId = ...
This is similar to how vinyl is using an extensible product type.↩︎
This is also technically possible with a custom
Middleware
, but it would be pretty hacky.↩︎
tags: haskell