servant-checked-exceptions

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 a FooError or BarError.
  • 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:

data DatabaseError = DatabaseError
data BlogPostNotFoundError = BlogPostNotFoundError

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 lookupBlogPost1.)

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 a FooError or BarError.

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:

  1. ToJSON / FromJSON instances for Envelope

    Right now, the end-user doesn't have any control over the ToJSON and FromJSON instances for the Envelope type. Some users may want to JSON-encode Envelope 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.

  2. 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 and FromJSON 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.

type ApiShortCircuit =
  "post" :>
  Capture "post-id" PostId :>
  Get '[JSON] BlogPost

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 compose MonadError e m with MonadError 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 from getBlogPostShortCircuit. Semantically, this doesn't make any sense.

Footnotes


  1. Here are type signatures for connectToDatabase and lookupBlogPost:

    -- | 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 = ...
    ↩︎
  2. This is similar to how vinyl is using an extensible product type.↩︎

  3. This is also technically possible with a custom Middleware, but it would be pretty hacky.↩︎

tags: haskell