As a longtime
http4s user I keep on learning new things and I’m always trying to come up with the best practices for writing http applications. This time I want to talk about my latest achievements in error handling within the context of an http application where it basically means mapping each business error to the appropiate http response.
So let’s get started by putting up an example of an http application with three different endpoints that interacts with a
UserAlgebra that may or may not fail with some specific errors.
If you are one of those who don’t like to read and prefer to jump straight into the code please find it here :)
We have a simple
UserAlgebra that let us perform some actions such as finding and persisting users.
case class User(username: String, age: Int) case class UserUpdateAge(age: Int) trait UserAlgebra[F[_]] { def find(username: String): F[Option[User]] def save(user: User): F[Unit] def updateAge(username: String, age: Int): F[Unit] }
And also an ADT of the possible errors that may arise. I’ll explain later in this post why it extends
Exception.
sealed trait UserError extends Exception case class UserAlreadyExists(username: String) extends UserError case class UserNotFound(username: String) extends UserError case class InvalidUserAge(age: Int) extends UserError
And here we have a simple interpreter for our
UserAlgebra for demonstration purposes so you can have an idea on how the logic would look like. In a real-life project an interpreter will more likely connect to a database instead of using an in-memory representaion based on
Ref.
import cats.effect.Sync import cats.effect.concurrent.Ref import cats.syntax.all._ object UserInterpreter { def create[F[_]](implicit F: Sync[F]): F[UserAlgebra[F]] = Ref.of[F, Map[String, User]](Map.empty).map { state => new UserAlgebra[F] { private def validateAge(age: Int): F[Unit] = if (age <= 0) F.raiseError(InvalidUserAge(age)) else F.unit override def find(username: String): F[Option[User]] = state.get.map(_.get(username)) override def save(user: User): F[Unit] = validateAge(user.age) *> find(user.username).flatMap { case Some(_) => F.raiseError(UserAlreadyExists(user.username)) case None => state.update(_.updated(user.username, user)) } override def updateAge(username: String, age: Int): F[Unit] = validateAge(age) *> find(username).flatMap { case Some(user) => state.update(_.updated(username, user.copy(age = age))) case None => F.raiseError(UserNotFound(username)) } } } }
The following implementation of
UserRoutes applies the tagless final encoding and the concept of “abstracting over the effect type” where we do not commit to a particular effect until the edge of our application.
import io.circe.generic.auto._ import io.circe.syntax._ import org.http4s._ import org.http4s.circe._ import org.http4s.circe.CirceEntityDecoder._ import org.http4s.dsl.Http4sDsl class UserRoutes) *> Ok(username) } } }
Now this particular implementation is missing a very important part: error handling. If we use the
UserAlgebra’s interpreter previously defined we will clearly miss the three errors defined by the
UserError ADT.
NOTE: If you are not familiar with these concepts make sure you check out my talk at Scala Matsuri early this year where I also talk about error handling in http applications using the Http4s library.
Okay let’s just go ahead and add some error handling to our http route by taking advantange of the
MonadError instance defined by our constraint
Sync[F] and making use of the syntax provided by
cats:
class UserRoutesAlt) }.handleErrorWith { case UserAlreadyExists(username) => Conflict(username.asJson) } case req @ PUT -> Root / "users" / username => req.as[UserUpdateAge].flatMap { userUpdate => userAlgebra.updateAge(username, userUpdate.age) *> Ok(username.asJson) }.handleErrorWith { case InvalidUserAge(age) => BadRequest(s"Invalid age $age".asJson) } } }
Now we can say this implementation is quite elegant! We are handling and mapping business errors to the according http response and our code compiles without any warning whatsoever. But wait… We are not handling the
UserNotFound error and the compiler didn’t tell us about it! That’s not cool and we as functional programmers believe in types because we can know what a function might do just by looking at the types but here it seems we hit the wall.
The problem is that our constraint of type
Sync from
cats-effect has a
MonadError instance with its type error fixed as
Throwable. So the compiler can’t help us here since this type is too generic. And we can’t add a constraint for
MonadError[F, UserError] because we would get an “ambigous implicits” error with two instances of
MonadError in scope.
So, what can we do about it?
I heard sometime ago about Classy Optics (Lenses, Prisms, etc) when I was learning Haskell and watched this amazing talk by George Wilson but I never got to use this concept in Scala until now!
Well first, let me give you a quick definition of
Lenses and
Prisms. In a few words we can define:
Lenses as getters and setters that compose making the accessing of nested data structure’s fields quite easy.
Prisms as first-class pattern matching that let us access branches of an ADT and that also compose.
And
Classy Optics as the idea of “associate with each type a typeclass full of optics for that type”.
So what am I talking about and how can these concepts help us solving the http error handling problem?
Remember that I defined the
UserError ADT by extending
Exception?
sealed trait UserError extends Exception case class UserAlreadyExists(username: String) extends UserError case class UserNotFound(username: String) extends UserError case class InvalidUserAge(age: Int) extends UserError
Well there’s a reason! By making
UserError a subtype of
Exception (and by default of
Throwable) we can take advantage of
Prisms by going back and forth in the types. See what I’m going yet?
UserRoute has a
Sync[F] constraint, meaning that we have available a
MonadError[F, Throwable] instance, but we would like to have
MonadError[F, UserError] instead to leverage the Scala compiler. The caveat is that the error types need to be of the same family so we can derive a
Prism that can navigate the errors types in one direction or another. But how do we derive it?
Fortunately our friend Oleg Pyzhcov has created this great library named meow-mtl that makes heavy use of Shapeless in order to derive
Lenses and
Prisms and it provides instances for some
cats-effect compatible datatypes.
And two of the supported typeclasses are
ApplicativeError and
MonadError as long as the error type is a subtype of
Throwable to make it compatible with
cats-effect. So we can do something like this:
import cats.MonadError import cats.effect.IO import com.olegpy.meow.hierarchy._ // All you need is this import! import scala.util.Random case class CustomError(msg: String) extends Throwable def customHandle[F[_], A](f: F[A], fallback: F[A])(implicit ev: MonadError[F, CustomError]): F[A] = f.handleErrorWith(_ => fallback) val io: IO[Int] = IO(Random.nextInt(2)).flatMap { case 1 => IO.raiseError(new Exception("boom")) } customHandle(io, IO.pure(123))
Now back to our use case. We can’t have a
MonadError[F, UserError] constraint because there’s already a
MonadError[F, Throwable] in scope given our
Sync[F] constraint. But it turns out we can make this work if we also abstract over the error handling by introducing an
HttpErrorHandler algebra where the error type is a subtype of
Throwable.
trait HttpErrorHandler[F[_], E <: Throwable] { def handle(routes: HttpRoutes[F]): HttpRoutes[F] } object HttpErrorHandler { def apply[F[_], E <: Throwable](implicit ev: HttpErrorHandler[F, E]) = ev }
UserRoutes can now have an additional constraint of type
HttpErrorHandler[F, UserError] so we clearly know what kind of errors we are dealing with and can have the Scala compiler on our side.
class UserRoutesMTL[F[_]: Sync](userAlgebra: UserAlgebra[F])(implicit H: HttpErrorHandler[F, UserError]) extends Http4sDsl[F] { private val httpRoutes:) *> Created(username.asJson) } } val routes: HttpRoutes[F] = H.handle(httpRoutes) }
We are basically delegating the error handling (AKA mapping business errors to appropiate http responses) to a specific algebra.
We also need an implementation for this algebra in order to handle errors of type
UserError but first we can introduce a
RoutesHttpErrorHandler object that encapsulates the repetitive task of handling errors given an
HttpRoutes[F]:
import cats.ApplicativeError import cats.data.{Kleisli, OptionT} object RoutesHttpErrorHandler { def apply[F[_], E <: Throwable](routes: HttpRoutes[F])(handler: E => F[Response[F]])(implicit ev: ApplicativeError[F, E]): HttpRoutes[F] = Kleisli { req: Request[F] => OptionT { routes.run(req).value.handleErrorWith { e => handler(e).map(Option(_)) } } } }
And our implementation:
class UserHttpErrorHandler[F[_]](implicit M: MonadError[F, UserError]) extends HttpErrorHandler[F, UserError] with Http4sDsl[F] { private val handler: UserError => F[Response[F]] = { case InvalidUserAge(age) => BadRequest(s"Invalid age $age".asJson) case UserAlreadyExists(username) => Conflict(username.asJson) case UserNotFound(username) => NotFound(username.asJson) } override def handle(routes: HttpRoutes[F]): HttpRoutes[F] = RoutesHttpErrorHandler(routes)(handler) }
If we forget to handle some errors the compiler will shout at us “match may not be exhaustive!” That’s fantastic :)
And the last part will be the wiring of all these components where we need to include the
meow-mtl import to figure out the derivation of the instances we need in order to make this work. It’ll look something like this if using
cats.effect.IO:
import com.olegpy.meow.hierarchy._ implicit val userHttpErrorHandler: HttpErrorHandler[IO, UserError] = new UserHttpErrorHandler[IO] UserInterpreter.create[IO].flatMap { UserAlgebra => val routes = new UserRoutesMTL[IO](UserAlgebra) IO.unit // pretend this is the rest of your program }
This is such an exciting time to be writing pure functional programming in Scala! The Typelevel ecosystem is getting richer and more mature, having an amazing set of libraries to solve business problems in an elegant and purely functional way.
I hope you have enjoyed this post and please do let me know if you know of better ways to solve this problem in the comments!
And last but not least I would like to thank all the friendly folks I hang out with in the
cats-effect,
cats,
fs2 and
http4s Gitter channels for all the time and effort they put (for free) into making this community an amazing space.
UPDATE: See the new article Error handling in Http4s with classy optics – Part 2.
Unless otherwise noted, all content is licensed under a Creative Commons Attribution 3.0 Unported License.Back to blog