ZIO : la gestion des erreurs avec Cause[E]

Un des types intéressants dans ZIO, qui est un peu caché sous le capôt, c’est zio.Cause.

ZIO[-R, +E, +A] indique de manière succincte que l’on peut gérer les erreurs avec E, qui va représenter soit un échec (ZIO.fail) de type E, ou les défaults (ZIO.die) de type Throwable. On va avoir aussi les Fatal Errors où l’on peut réagir un peu avant que cela tue l’application.

ZIO accumule les erreurs de manière parallèle et séquentielle, et permet de faire de la récupération sur les différentes erreurs (faillure/defect).

Le type intéressant qui permet de faire ça c’est zio.Cause[+E]. Cause est un type de data algébrique, qui facilite la représentation de la composition des erreurs :

sealed trait Cause[+E]

case object Empty extends Cause[Nothing]

case class Fail[+E](value : E, trace : zio.StackTrace) extends Cause[E]
case class Die(value : Throwable, trace : zio.StackTrace) extends Cause[Nothing]

case class Interrupt(fiberId : zio.FiberId, trace : zio.StackTrace) extends Cause[Nothing]
case class Stackless[+E](cause : Cause[E], stackless : Boolean) extends Cause[E]

case class Then[+E](left : Cause[E], right : Cause[E]) extends Cause[E]
case class Both[+E](left : Cause[E], right : Cause[E]) extends Cause[E]

API

trait ZIO[-R, +E, +A] {
  def sandbox: ZIO[R, Cause[E], A]
  def tapErrorCause[R1 <: R, E1 >: E](f: Cause[E] => ZIO[R1, E1, Unit]): ZIO[R1, E1, A]
  
  def catchAllCause[R1 <: R, E2, A1 >: A](h : Cause[E] => ZIO[R1, E2, A1]]: ZIO[R1, E2, A1]
  def catchSomeCause[R1 <: R, E1 >: E, A1 >: A](pf : Cause[E] => ZIO[R1, E1, A1]): ZIO[R1, E1, A1]
    
  def cause: URIO[R, Cause[E]] //pour un A, on va avoir un Cause.Empty

  def foldCause[B](failure: Cause[E] => B, success: A => B): URIO[R, B]
  def foldCauseZIO[R1 <: R, E2, B](failure : Cause[E] => ZIO[R1, E2, B], success: A => ZIO[R1, E2, B]): ZIO[R1, E2, B]
  
  def mapErrorCause[E2](h : Cause[E] => Cause[E2]): ZIO[R, E2, A]
  def onDoneCause[R1 <: R](error: Cause[E] => zio.ZIO[R1, Nothing, Any], success: A => zio.ZIO[R1, Nothing, Any]): ZIO[R1, Nothing, Unit]
}

on[R, E, A](zio: ZIO[R, Cause[E], A] {
	def unsandbox: ZIO[R, E, A]
}

on[R, E](zio: ZIO[R, E, Cause[E]) {
  def uncause: ZIO[R, E, Unit]
}

object ZIO {
  def failCause[E](cause : => Cause[E]): IO[E, Nothing]
  def fromEitherCause[E, A](v : => Either[Cause[E], A]): IO[E, A]

  def logCause(cause: => Cause[Any]) : zio.UIO[scala.Unit]
  def logCause(message: => String, cause : => Cause[Any]): UIO[scala.Unit]
  
  def logDebugCause(message: => String, cause : => Cause[scala.Any]): zio.UIO[scala.Unit] 
  def logDebugCause(cause: => Cause[scala.Any]): UIO[scala.Unit]
 
  def logErrorCause(message: => String, cause : => Cause[scala.Any]): zio.UIO[scala.Unit] 
  def logErrorCause(cause: => Cause[scala.Any]): zio.UIO[scala.Unit]
  def logFatalCause(message: => String, cause : => zio.Cause[scala.Any]): zio.UIO[scala.Unit] 
  def logFatalCause(cause: => Cause[scala.Any]): zio.UIO[scala.Unit]

  def logInfoCause(message: => String, cause : => zio.Cause[scala.Any]): zio.UIO[scala.Unit] = 
  def logInfoCause(cause: => zio.Cause[scala.Any]): UIO[Unit]

  def logTraceCause(message: => String, cause : => zio.Cause[scala.Any]): zio.UIO[Unit]
  def logTraceCause(cause: => zio.Cause[scala.Any]): zio.UIO[Unit]

  def logWarningCause(message: => String, cause : => zio.Cause[scala.Any]): UIO[scala.Unit]
  def logWarningCause(cause: => Cause[scala.Any]): zio.UIO[scala.Unit]

  def refailCause[E](cause : Cause[E]): zio.ZIO[Any, E, Nothing]

  def unsandbox[R, E, A](v : => ZIO[R, Cause[E], A]) : ZIO[R, E, A]
}

Cas d’utilisation

Comprendre ce qui se passe quand tout se déroule mal

Parfois, on ne comprend pas pourquoi un effet est en échec, cause permet de récupérer de quoi tout expliquer s’il n’y a pas d’autres moyens

effect.tapErrorCause(cause => ZIO.logCause("please help me understand", cause))

Récupérer les erreurs des exécutions parallèles

Composer d’autres façons de lancer les effets

un des problèmes que l’on a récemment sur un pipeline de gestion de notification en batch, c’est l’envoi simultané de SMS et d’EMAIL. L’utilisation de zipPar pour faire ça n’est pas pratique. Cela tente d’interrompre l’effet restant en cas d’erreur d’un côté. On peut éviter ce comportement au niveau de la composition soit :

  • en rendant certains effets non-interruptibles ( .uninterruptible )
  • soit avec un nouveau combinateur
    def zipParNoInterruptOnError[R, E, A, B](zio1:ZIO[R, E, A], zio2:ZIO[R, E, B]): ZIO[R, E, (A, B)] = {
        zio1.sandbox.either.zipPar(zio2.sandbox.either).map({
          case (Right(a), Right(b)) => Right((a,b))
          case (Left(e1), Left(e2)) => Left(Cause.Both(e1, e2))
          case (Left(e1), _) => Left(e1)
          case (_, Left(e2)) => Left(e2)
        }).absolve.unsandbox
      }

L’avantage de ce type de combinateur, ce n'est que cela permet toujours d’interrompre plus tard la composition, et de récupérer plusieurs erreurs s’il y en a plusieurs

Conclusion

C’est intéressant que ZIO nous permette sans rentrer des structures interne de contrôler plus finement la gestion d’erreur.

Si vous voulez aller plus loin, récemment Wiem a fait un talk sur le sujet : https://www.youtube.com/watch?v=CnXR7RPMAs8

Liens

Sandboxing | ZIO
We know that a ZIO effect may fail due to a failure, a defect, a fiber interruption, or a combination of these causes. So a ZIO effect may contain more than one cause. Using the ZIO#sandbox operator, we can sandbox all errors of a ZIO application, whether the cause is a failure, defect, or a fiber interruption or combination of these. This operator exposes the full cause of a ZIO effect into the error channel:
Cause | ZIO
The ZIO[R, E, A] effect is polymorphic in values of type E and we can work with any error type that we want, but there is a lot of information that is not inside an arbitrary E value. So as a result ZIO needs somewhere to store things like unexpected errors or defects, stack and execution traces, cause of fiber interruptions, and so forth.