by Gabriel Volpe on Nov 28, 2018
technical
This is a continuation of my previous blog post. Make sure you have read that one before continuing here.
I recently gave a 20 minutes talk on classy optics
at the unconference of Scale by the Bay where I also talked about this error handling technique and on my way back home I was still thinking of different ways of doing this. So, after some exploratory work, I came up with a few different alternatives.
Something that made me cringe and that a few of my colleagues at work were not happy with was that the algebras had no association with the error type defined in HttpErrorHandler[F, E]
so the type-safety was down to the programmer’s discipline and in this case the compiler was not able to do much.
When working with EitherT[F, E, A]
or a bifunctor IO[E, A]
we have a clear error type whereas by just relying on a single F[A]
with a MonadError[F, Throwable]
instance we lose this property. There are a few issues with the first though:
Left
or via its effect type IO
.flatMap
calls and extra allocations.Either
values into the transformer stack.The IO[E, A]
model is naturally a better approach but I found out polymorphic code is more cumbersome than working with F[A]
. Although this might change once Cats Effect 2.0 is out, it’ll take a while until we get there.
What I like about the IO[E, A]
model is that we can distinguish between “business errors” and “unexpected failures” such as a database connection failure (learn more about zio
’s error model here). Eg: when working on a REST API, most of the time we only care about mapping a few business errors into the appropriate http responses. The unexpected failures should be handled by someone else. In this case http4s
will convert any failure into a response with code 500 (internal server error).
And this is exactly what we want to achieve here. Writing polymorphic code using cats-effect
while trying to keep it as simple as possible. Here’s an encoding I would like to explore further:
In the previous blog post we defined the algebras as a single trait. In this case we are going to try a different encoding but first we need to introduce an ErrorChannel[F, E]
typeclass where the error type is a subtype of Throwable
to be compatible with the error type of the cats-effect
typeclasses:
trait ErrorChannel[F[_], E <: Throwable] {
def raise[A](e: E): F[A]
}
An instance can be derived for any ApplicativeError[F, Throwable]
so we don’t need to write it manually for every error type.
import cats.ApplicativeError
object ErrorChannel {
def apply[F[_], E <: Throwable](implicit ev: ErrorChannel[F, E]) = ev
implicit def instance[F[_], E <: Throwable](implicit F: ApplicativeError[F, Throwable]): ErrorChannel[F, E] =
new ErrorChannel[F, E] {
override def raise[A](e: E) = F.raiseError(e)
}
object syntax {
implicit class ErrorChannelOps[F[_]: ErrorChannel[?[_], E], E <: Throwable](e: E) {
def raise[A]: F[A] = ErrorChannel[F, E].raise[A](e)
}
}
}
Our UserAlg
will now be defined as an abstract class
instead in order to be able to add typeclass constraint.
case class User(username: String, age: Int)
case class UserUpdateAge(age: Int)
abstract class UserAlg[F[_]: ErrorChannel[?[_], E], E <: Throwable] {
def find(username: String): F[Option[User]]
def save(user: User): F[Unit]
def updateAge(username: String, age: Int): F[Unit]
}
And here’s the ADT of the possible errors that may arise (notice the extends Exception
part):
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
We want to make sure our ADT is a subtype of Throwable
and indeed Exception <: Throwable
.
Here’s a similar UserAlg
interpreter to the one presented in the previous post. Note that in a real-life project an interpreter will more likely connect to a database instead of using an in-memory representation based on Ref
.
The interesting part is that in order to construct a UserAlg[F, UserError]
we now need an ErrorChannel[F, UserError]
instance in scope. This will be the chosen strategy to report errors in the context of F
.
import cats.effect.{ Concurrent, Sync }
import cats.effect.concurrent.Ref
import cats.syntax.all._
object UserInterpreter {
def mkUserAlg[F[_]: Sync](implicit error: ErrorChannel[F, UserError]): F[UserAlg[F, UserError]] =
Ref.of[F, Map[String, User]](Map.empty).map { state =>
new UserAlg[F, UserError] {
private def validateAge(age: Int): F[Unit] =
if (age <= 0) error.raise(InvalidUserAge(age)) else ().pure[F]
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(_) =>
error.raise(UserAlreadyExists(user.username))
// error.raise(new Exception("asd")) // Does not compile
// Sync[F].raiseError(new Exception("")) // Should be considered an unrecoverable failure
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 =>
error.raise(UserNotFound(username))
}
}
}
}
Notice that we could still call Sync[F].raiseError(new Exception("boom"))
and it will still compile. However, if we choose to use ErrorChannel
to signal business errors we will have the compiler on our side and it’ll warn us when we try to raise an error that is not part of the ADT we have declared. So signaling error in a different way should just be considered unrecoverable. These are the same semantics you get when working with EitherT[IO, Throwable, ?]
as shown in the comparison table at the beginning.
Here’s the same HttpErrorHandler
defined in the previous blog post:
import cats.{ ApplicativeError, MonadError }
import cats.data.{ Kleisli, OptionT }
import org.http4s._
trait HttpErrorHandler[F[_], E <: Throwable] {
def handle(routes: HttpRoutes[F]): HttpRoutes[F]
}
object RoutesHttpErrorHandler {
def apply[F[_]: ApplicativeError[?[_], E], E <: Throwable](
routes: HttpRoutes[F]
)(handler: E => F[Response[F]]): HttpRoutes[F] =
Kleisli { req =>
OptionT {
routes.run(req).value.handleErrorWith(e => handler(e).map(Option(_)))
}
}
}
object HttpErrorHandler {
def apply[F[_], E <: Throwable](implicit ev: HttpErrorHandler[F, E]) = ev
def mkInstance[F[_]: ApplicativeError[?[_], E], E <: Throwable](
handler: E => F[Response[F]]
): HttpErrorHandler[F, E] =
(routes: HttpRoutes[F]) => RoutesHttpErrorHandler(routes)(handler)
}
Now let’s look at the new implementation of UserRoutes
using the error-type algebra:
import cats.effect.Sync
import cats.syntax.all._
import io.circe.generic.auto._
import io.circe.syntax._
import org.http4s._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe._
import org.http4s.dsl.Http4sDsl
class PreUserRoutesMTL[F[_]: Sync](users: UserAlg[F, UserError]) extends Http4sDsl[F] {
private val httpRoutes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "users" / username =>
users.find(username).flatMap {
case Some(user) => Ok(user.asJson)
case None => NotFound(username.asJson)
}
case req @ POST -> Root / "users" =>
req.as[User].flatMap { user =>
users.save(user) *> Created(user.username.asJson)
}
case req @ PUT -> Root / "users" / username =>
req.as[UserUpdateAge].flatMap { userUpdate =>
users.updateAge(username, userUpdate.age) *> Created(username.asJson)
}
}
def routes(implicit H: HttpErrorHandler[F, UserError]): HttpRoutes[F] =
H.handle(httpRoutes)
}
Notice that in contrast to the example shown in the previous blog post there is now a relationship between UserAlg
and HttpErrorHandler
: the error type is the same. However, this is not enforced by the compiler. Can we be more strict about it?
We could define a generic Routes[F, E]
:
abstract class Routes[F[_], E <: Throwable](implicit H: HttpErrorHandler[F, E]) extends Http4sDsl[F] {
protected def httpRoutes: HttpRoutes[F]
val routes: HttpRoutes[F] = H.handle(httpRoutes)
}
But we’ll also need something else to connect the error types of the algebra and the http error handler:
abstract class UserRoutes[F[_]: HttpErrorHandler[?[_], E], E <: Throwable](
users: UserAlg[F, E]
) extends Routes[F, E]
That’s it! We are now enforcing this relationship at compile time. Let’s see how the HttpRoutes
looks like:
class UserRoutesAlt[F[_]: HttpErrorHandler[?[_], UserError]: Sync](
users: UserAlg[F, UserError]
) extends UserRoutes(users) {
protected val httpRoutes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "users" / username =>
users.find(username).flatMap {
case Some(user) => Ok(user.asJson)
case None => NotFound(username.asJson)
}
case req @ POST -> Root / "users" =>
req
.as[User]
.flatMap { user =>
users.save(user) *> Created(user.username.asJson)
}
case req @ PUT -> Root / "users" / username =>
req
.as[UserUpdateAge]
.flatMap { userUpdate =>
users.updateAge(username, userUpdate.age) *> Ok(username.asJson)
}
}
}
Neat! Right? If we try to change the error type of UserAlg
it wouldn’t compile!
In most of my programs I tend to specify an HttpRoute
per algebra. But what if we wanted to just define a single HttpRoute
that uses multiple algebras? There are a couple of options.
Let’s first define a new ADT of errors and a new algebra to illustrate the problem:
sealed trait CatalogError extends Exception
case class ItemAlreadyExists(item: String) extends CatalogError
case class CatalogNotFound(id: Long) extends CatalogError
case class Item(name: String) extends AnyVal
abstract class CatalogAlg[F[_]: ErrorChannel[?[_], E], E <: Throwable] {
def find(id: Long): F[List[Item]]
def save(id: Long, item: Item): F[Unit]
}
Here we have an HttpRoutes
that makes use of two algebras with different error types:
class UserRoutesMTL[F[_]: Sync](
users: UserAlg[F, UserError],
catalog: CatalogAlg[F, CatalogError]
) extends Http4sDsl[F] {
private val httpRoutes: HttpRoutes[F] = ???
def routes(
implicit H1: HttpErrorHandler[F, UserError],
H2: HttpErrorHandler[F, CatalogError]
): HttpRoutes[F] =
H2.handle(H1.handle(httpRoutes))
}
It works! But it’s not as elegant as we would like it to be and if we add more algebras this would quicky get out of control.
Can we generalize this pattern?
We can define our error type as a coproduct of different errors, in our case UserError
and CatalogError
. For example:
import shapeless._
def routes[F[_]](implicit H: HttpErrorHandler[F, UserError :+: CatalogError :+: CNil]) = ???
However, this doesn’t compile because the error type is no longer a subtype of Throwable
. It is now a Coproduct
.
But we might be able to derive an instance for a coproduct of errors if we have an instance of HttpErrorHandler[F, E]
for each error type. Let’s give it a try! We need to define a new typeclass CoHttpErrorHandler
:
import shapeless._
trait CoHttpErrorHandler[F[_], Err <: Coproduct] {
def handle(routes: HttpRoutes[F]): HttpRoutes[F]
}
object CoHttpErrorHandler {
def apply[F[_], Err <: Coproduct](implicit ev: CoHttpErrorHandler[F, Err]) = ev
implicit def cNilInstance[F[_]]: CoHttpErrorHandler[F, CNil] =
(routes: HttpRoutes[F]) => routes
implicit def consInstance[F[_], E <: Throwable, T <: Coproduct](
implicit H: HttpErrorHandler[F, E],
CH: CoHttpErrorHandler[F, T]
): CoHttpErrorHandler[F, E :+: T] =
(routes: HttpRoutes[F]) => CH.handle(H.handle(routes))
}
Voilà! We introduced a CoHttpErrorHandler
where the error type is a coproduct and the instance can only be derived if each type is a subtype of Throwable
making it impossible to define an invalid coproduct. So it compiles! But how do we use it?
class CoUserRoutesMTL[F[_]: Sync](
users: UserAlg[F, UserError],
catalog: CatalogAlg[F, CatalogError]
) extends Http4sDsl[F] {
private val httpRoutes: HttpRoutes[F] = ???
def routes(implicit CH: CoHttpErrorHandler[F, UserError :+: CatalogError :+: CNil]): HttpRoutes[F] =
CH.handle(httpRoutes)
}
Yay!!! Now this is more elegant and generic so we can re-use the same pattern in different routes. But now again we have lost the relationship between the error types of the algebras and the error type of CoHttpErrorHandler
. So maybe we could do something similar to what we have done previously?
It’s possible but in the case of coproducts we need to introduce some boilerplate…
abstract class CoRoutes[F[_], E <: Coproduct](implicit CH: CoHttpErrorHandler[F, E]) extends Http4sDsl[F] {
protected def httpRoutes: HttpRoutes[F]
val routes: HttpRoutes[F] = CH.handle(httpRoutes)
}
This one is pretty basic and similar to Routes
defined before.
abstract class CoUserRoutes[
F[_]: CoHttpErrorHandler[?[_], E],
A <: Throwable,
B <: Throwable,
E <: Coproduct: =:=[?, A :+: B :+: CNil]
](
users: UserAlg[F, A],
catalog: CatalogAlg[F, B]
) extends CoRoutes[F, E]
type CustomError = UserError :+: CatalogError :+: CNil
Here we have a couple of constraints:
F[_]
needs to have an instance of CoHttpErrorHandler[F, E]
.A
and B
are the error types of the two algebras.E
needs to be a Coproduct
of type A :+: B :+: CNil
.class CoUserRoutesMTL[F[_]: CoHttpErrorHandler[?[_], CustomError]: Sync](
users: UserAlg[F, UserError],
catalog: CatalogAlg[F, CatalogError]
) extends CoUserRoutes(users, catalog) {
protected val httpRoutes: HttpRoutes[F] = ???
}
Now we are saying that the error type of our CoHttpErrorHandler
is a coproduct of each error type of the algebras. And we wouldn’t be able to change the error type of any of them without getting a compiler error.
You can see all the compiling examples here. Make sure you check out all the different branches.
The last approach is probably too much but we have demonstrated that it’s possible to push the boundaries to make our application very type-safe. However, we also need to consider the trade-offs of writing more boilerplate.
Personally, I settle for the previous approach where the error type of the algebra matches the error type of the HttpErrorHandler
even if it requires a bit more of discipline. The choice is yours! Just make sure you understand the trade-offs of every mechanism.
I hope you have enjoyed this post and please do let me know if you have other ideas to keep broadening my understanding!