Skip to main content
Version: 2.x

Cause

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.

ZIO is very strict about preserving the full information related to a failure. It captures all type of errors into the Cause data type. ZIO uses Cause[E] to store the full story of failure, so its error model is lossless. It doesn't throw away information related to the failure result. So we can figure out exactly what happened during the operation of our effects.

It is important to note that Cause is the underlying data type for the ZIO data type, and we don't usually deal with it directly. Even though we do not deal with it very often, anytime we want, we can access the Cause data structure, which gives us total access to all parallel and sequential errors in our codebase.

Cause Internals

ZIO uses a data structure from functional programming called a semiring for the Cause data type. It allows us to take a base type E that represents the error type and then capture the sequential and parallel composition of errors in a fully lossless fashion.

The following snippet shows how Cause is designed as a semiring data structure:

sealed abstract class Cause[+E] extends Product with Serializable { self =>
import Cause._
def trace: Trace = ???

final def ++[E1 >: E](that: Cause[E1]): Cause[E1] = Then(self, that)
final def &&[E1 >: E](that: Cause[E1]): Cause[E1] = Both(self, that)
}

object Cause extends Serializable {
case object Empty extends Cause[Nothing]
final case class Fail[+E](value: E, override val trace: Trace) extends Cause[E]
final case class Die(value: Throwable, override val trace: Trace) extends Cause[Nothing]
final case class Interrupt(fiberId: FiberId, override val trace: Trace) extends Cause[Nothing]
final case class Stackless[+E](cause: Cause[E], stackless: Boolean) extends Cause[E]
final case class Then[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
final case class Both[+E](left: Cause[E], right: Cause[E]) extends Cause[E]
}

Using the Cause data structure described above, ZIO can capture all errors inside the application.

Cause Variations

There are several causes for various errors. In this section, we will describe each of these causes. We will see how they can be created manually or how they will be automatically generated as the underlying error management data type of a ZIO application.

Empty

The Empty cause indicates the lack of errors. We use Cause.empty constructor to create an Empty cause. Using ZIO.failCause we can create a ZIO effect that has an empty cause:

import zio._

ZIO.failCause(Cause.empty).cause.debug
// Empty

Also, we can use ZIO#cause to uncover the underlying cause of an effect. For example, we know that ZIO.succeed(5) has no errors. So, let's check that:

ZIO.succeed(5).cause.debug
// Empty

ZIO.attempt(5).cause.debug
// Empty

Fail

The Fail cause indicates the cause of an expected error of type E. We can create one using the Cause.fail constructor:

import zio._

ZIO.failCause(Cause.fail("Oh uh!")).cause.debug
// Fail(Oh uh!,Trace(Runtime(2,1646395282),Chunk(<empty>.MainApp.run(MainApp.scala:4))))

Let's uncover the cause of some ZIO effects especially when we combine them:

import zio._

ZIO.fail("Oh uh!").cause.debug
// Fail(Oh uh!,Trace(Runtime(2,1646395627),Chunk(<empty>.MainApp.run(MainApp.scala:3))))

(ZIO.fail("Oh uh!") *> ZIO.dieMessage("Boom!") *> ZIO.interrupt).cause.debug
// Fail(Oh uh!,Trace(Runtime(2,1646396370),Chunk(<empty>.MainApp.run(MainApp.scala:6))))

(ZIO.fail("Oh uh!") <*> ZIO.fail("Oh Error!")).cause.debug
// Fail(Oh uh!,Trace(Runtime(2,1646396419),Chunk(<empty>.MainApp.run(MainApp.scala:9))))

val myApp: ZIO[Any, String, Int] =
for {
i <- ZIO.succeed(5)
_ <- ZIO.fail("Oh uh!")
_ <- ZIO.dieMessage("Boom!")
_ <- ZIO.interrupt
} yield i
myApp.cause.debug
// Fail(Oh uh!,Trace(Runtime(2,1646397126),Chunk(<empty>.MainApp.myApp(MainApp.scala:13),<empty>.MainApp.run(MainApp.scala:17))))

Die

The Die cause indicates a defect, an unexpected failure of type Throwable. It contains the stack trace of the defect that occurred. We can use Cause.die to create one:

import zio._

ZIO.failCause(Cause.die(new Throwable("Boom!"))).cause.debug
// Die(java.lang.Throwable: Boom!,Trace(Runtime(2,1646479908),Chunk(<empty>.MainApp.run(MainApp.scala:3))))

If we have a bug in our code and something throws an unexpected exception, that information would be described inside a Die. Let's try to investigate some ZIO code that will die:

import zio._

ZIO.succeed(5 / 0).cause.debug
// Die(java.lang.ArithmeticException: / by zero,Trace(Runtime(2,1646480112),Chunk(zio.internal.FiberContext.runUntil(FiberContext.scala:538),<empty>.MainApp.run(MainApp.scala:3))))

ZIO.dieMessage("Boom!").cause.debug
// Stackless(Die(java.lang.RuntimeException: Boom!,Trace(Runtime(2,1646398246),Chunk(<empty>.MainApp.run(MainApp.scala:7)))),true)

It is worth noting that the latest example is wrapped by the Stackless cause in the previous example. We will discuss Stackless further, but for now, it is enough to know that Stackless includes fewer stack traces than the Die cause.

Interrupt

The Interrupt cause indicates a fiber interruption which contains information of the fiber id of the interrupted fiber, and also the corresponding stack trace. Let's try an example of:

import zio._

ZIO.interrupt.cause.debug
// Interrupt(Runtime(2,1646471715),Trace(Runtime(2,1646471715),Chunk(<empty>.MainApp.run(MainApp.scala:3))))

ZIO.never.fork
.flatMap(f => f.interrupt *> f.join)
.cause
.debug
// Interrupt(Runtime(2,1646472025),Trace(Runtime(13,1646472025),Chunk(<empty>.MainApp.run(MainApp.scala:7))))

Stackless

The Stackless cause stores stack traces and execution traces. It has a boolean stackless flag which denotes whether the ZIO runtime should print the full stack trace of the inner cause or just print a few lines of it.

For example, ZIO.dieMessage uses Stackless:

import zio._

ZIO.dieMessage("Boom!").cause.debug
// Stackless(Die(java.lang.RuntimeException: Boom!,Trace(Runtime(2,1646477970),Chunk(<empty>.MainApp.run(MainApp.scala:3)))),true)

So when we run it the following stack traces will be printed:

timestamp=2022-03-05T11:08:19.530710679Z level=ERROR thread=#zio-fiber-0 message="Exception in thread "zio-fiber-2" java.lang.RuntimeException: Boom!
at <empty>.MainApp.run(MainApp.scala:3)"

While ZIO.die doesn't use Stackless cause:

import zio._

ZIO.die(new Throwable("Boom!")).cause.debug
// Die(java.lang.Exception: Boom!,Trace(Runtime(2,1646479093),Chunk(<empty>.MainApp.run(MainApp.scala:3))))

So it prints the full stack trace:

timestamp=2022-03-05T11:19:12.666418357Z level=ERROR thread=#zio-fiber-0 message="Exception in thread "zio-fiber-2" java.lang.Exception: Boom!
at MainApp$.$anonfun$run$1(MainApp.scala:4)
at zio.ZIO$.$anonfun$die$1(ZIO.scala:3384)
at zio.internal.FiberContext.runUntil(FiberContext.scala:255)
at zio.internal.FiberContext.run(FiberContext.scala:115)
at zio.internal.ZScheduler$$anon$1.run(ZScheduler.scala:151)
at <empty>.MainApp.run(MainApp.scala:4)"

Both

When we are doing parallel computation, the effect can fail for more than one reason. If we are doing two things at once and both of them fail then we actually have two errors. So, the Both cause stores the composition of two parallel causes.

For example, if we run two parallel fibers with zipPar and all of them fail, their causes will be encoded with Both:

import zio._

val myApp: ZIO[Any, String, Unit] =
for {
f1 <- ZIO.fail("Oh uh!").fork
f2 <- ZIO.dieMessage("Boom!").fork
_ <- (f1 <*> f2).join
} yield ()
myApp.cause.debug
// Both(Fail(Oh uh!,Trace(Runtime(13,1646481219),Chunk(<empty>.MainApp.myApp(MainApp.scala:5)))),Stackless(Die(java.lang.RuntimeException: Boom!,Trace(Runtime(14,1646481219),Chunk(<empty>.MainApp.myApp(MainApp.scala:6)))),true))

If we run the myApp effect, in the stack trace we can see two exception traces occurred on two separate fibers:

timestamp=2022-03-05T12:37:46.831096692Z level=ERROR thread=#zio-fiber-0 message="Exception in thread "zio-fiber-13" java.lang.String: Oh uh!
at <empty>.MainApp.myApp(MainApp.scala:5)
Exception in thread "zio-fiber-14" java.lang.RuntimeException: Boom!
at <empty>.MainApp.myApp(MainApp.scala:6)"

Other parallel operators are also the same, for example, ZIO encodes the underlying cause of (ZIO.fail("Oh uh!") <&> ZIO.dieMessage("Boom!")) with the Both cause.

Then

ZIO uses Then cause to encode sequential errors. For example, if we perform ZIO's analog of try-finally (e.g. ZIO#ensuring), and both try and finally blocks fail, their causes are encoded with Then:

import zio._

val myApp =
ZIO.fail("first")
.ensuring(ZIO.die(throw new Exception("second")))

myApp.cause.debug
// Then(Fail(first,Trace(Runtime(2,1646486975),Chunk(<empty>.MainApp.myApp(MainApp.scala:4),<empty>.MainApp.myApp(MainApp.scala:5),<empty>.MainApp.run(MainApp.scala:7)))),Die(java.lang.Exception: second,Trace(Runtime(2,1646486975),Chunk(zio.internal.FiberContext.runUntil(FiberContext.scala:538),<empty>.MainApp.myApp(MainApp.scala:5),<empty>.MainApp.run(MainApp.scala:7)))))

If we run the myApp effect, we can see the following stack trace:

timestamp=2022-03-05T13:30:17.335173071Z level=ERROR thread=#zio-fiber-0 message="Exception in thread "zio-fiber-2" java.lang.String: first
at <empty>.MainApp.myApp(MainApp.scala:4)
at <empty>.MainApp.myApp(MainApp.scala:5)
Suppressed: java.lang.Exception: second
at MainApp$.$anonfun$myApp$3(MainApp.scala:5)
at zio.ZIO$.$anonfun$die$1(ZIO.scala:3384)
at zio.internal.FiberContext.runUntil(FiberContext.scala:255)
at zio.internal.FiberContext.run(FiberContext.scala:115)
at zio.internal.ZScheduler$$anon$1.run(ZScheduler.scala:151)
at zio.internal.FiberContext.runUntil(FiberContext.scala:538)
at <empty>.MainApp.myApp(MainApp.scala:5)"

As we can see in the above stack trace, the first failure was suppressed by the second defect.