Sunday, June 28, 2020

Error handling - the Cats way


Exceptions in the Java world suck as you don't necessarily know what will be "returned" by a method. Even if the exception is declared (and they often are not) then every method can "return" your expected value or maybe an Exception.  

So, how are they handled in the FP library, Cats?

Why is MonadError useful?

As ever, Rob Norris gives an immensely insightful answer on Gitter:

laiboonh @laiboonh Jun 27 14:44
Why can't I simply represent error with Either for example

Rob Norris @tpolecat Jun 27 14:45
It lets you abstract over things like Either.
 
Which is kind of a bad answer.
Some effects have an error channel. IO for instance. When you're talking to a database or something there are a billion things that can go wrong and you'll spend your whole life passing Eithers around, containing possible errors that you'll never be able to handle. So it's nicer to bury them in the effect itself and only summon them when you're in a position to do something about them.
 
Either can work this way. Your effect can be Either[MyError, ?]
But you can also have something like IO[Either[MyError, A]] which has an error channel for "bad" errors and an explicit Either for errors that you want the user to confront immediately. Kind of like runtime vs. checked exceptions in Java.
 
In any case MonadError lets you abstract over effects that have an error channel.
So you can write code that works with IO or Future or Try or Either or ...

@ def tryDiv[F[_]](a: Int, b: Int)(implicit ev: ApplicativeError[F, Throwable]): F[Int] =
    if (b != 0) ev.pure(a / b) else ev.raiseError(new ArithmeticException)
defined function tryDiv

@ tryDiv[IO](3, 1).unsafeRunSync
res4: Int = 3

@ tryDiv[IO](3, 0).unsafeRunSync
java.lang.ArithmeticException
  ammonite.$sess.cmd3$.tryDiv(cmd3.sc:2)
  ammonite.$sess.cmd5$.<clinit>(cmd5.sc:1)

@ tryDiv[Either[Throwable, ?]](3, 1)
res6: Either[Throwable, Int] = Right(3)

@ tryDiv[Either[Throwable, ?]](3, 0)
res7: Either[Throwable, Int] = Left(java.lang.ArithmeticException)

@ tryDiv[Try](3, 1)
res9: Try[Int] = Success(3)

@ tryDiv[Try](3, 0)
res10: Try[Int] = Failure(java.lang.ArithmeticException)


ApplicativeError and Option

You'll notice that Rob doesn't mention Option and there's a good reason for that. "This will not work for Option though : instances of these constructs do not exist for Options (the reason becomes obvious if you think 5 minutes about it)." [Reddit]

The answer for the impatient is because None carries no further information.  and "you will be throwing away some information and behaviour." [Ruben Pieters blog]

"I've played around with this a bit and it definitely breaks the existing laws for MonadError (and ApplicativeError) since the error value is always thrown away." [Cats Github issue]


Handling Errors

We can handle errors monadically avoiding the short-circuiting mechanics that you'd expect with a monad:

  val o1:     IO[Int] = IO(1)
  val o2:     IO[Int] = IO(2)
  val badBoy: IO[Int] = IO(1/0)

  val handledErrors: IO[Int] = for {
    a <- badBoy.handleErrorWith(_ => o1)
    b <- badBoy.orElse(o2)
  } yield a + b

Note that orElse is syntactic sugar and just defers to handleErrorWith

There's some nice error handling code in fs2-kafka:

(
        implicit F:      MonadError[F, Throwable],
                 jitter: Jitter[F],
                 timer:  Timer[F]
      )
...
        def retry(attempt: Int): Throwable => F[Unit] = {
          case retriable: RetriableCommitFailedException =>
            val commitWithRecovery = commit.handleErrorWith(retry(attempt + 1))
            if (attempt <= 10) backoff(attempt).flatMap(timer.sleep) >> commitWithRecovery
            else if (attempt <= 15) timer.sleep(10.seconds) >> commitWithRecovery
            else F.raiseError(CommitRecoveryException(attempt - 1, retriable, offsets))

          case nonRetriable: Throwable =>
            F.raiseError(nonRetriable)
        }

Nicely, this is stacking Fs like beads on a thread. Some Fs pause (calls to backoff return  F[_]s) and some Fs do the actual work of trying to commit (commit is a  F[Unit]). But whatever problems we face, we can call F.raiseError and no matter what F actually is, the appropriate semantics of its type will be used to return a pass or a fail.

Conclusion

As long as your effect has a type class of MonadError or ApplicativeError, you can raise an error. What the nature of this error is depends entirely on the effect.


No comments:

Post a Comment