We’ll look at some examples of different kinds of exceptions and how we can deal with them in ZIO.

To do so, we’ll use this dangerous function to create exceptions

object DangerousCode {
  def danger(i: Int): ZIO[Any, Any, Unit] = i match {
    case 0 => ZIO.unit
    case 1 => ZIO.fail("explicit failure")
    case 2 => ZIO.attempt(throw new RuntimeException("failure from a caught exception"))
    case 3 => ZIO.die(new RuntimeException("explicit defect"))
    case 4 => ZIO.unit.map(_ => throw new RuntimeException("uncaught exception"))
    case 5 => ZIO.fail(new RuntimeException("first exception")).ensuring(ZIO.die(new RuntimeException("second exception")))
    case 6 => ZIO.attempt(throw new StackOverflowError("Fatal error that cannot be caught."))
  }
}

Then we’ll (try to) catch them in this example application. We catch the exceptions in the safe function, but it can still emit IOException from the Console.printline

object ExceptionApp extends ZIOAppDefault {
  def run = for {
    _ <- zio.Console.printLine("Example app start")
    _ <- ZIO.foreach(Range(0, Int.MaxValue))(safe)
    _ <- zio.Console.printLine("Example app end")
  } yield ()

  def safe(i: Int): ZIO[Any, IOException, Unit] =
    (DangerousCode.danger(i) *> Console.printLine("ok"))
      .catchAll(t => Console.printLine(s"caught failure $t"))
      .catchAllDefect(t => Console.printLine(s"caught defect $t"))
}

Let’s discuss the individual examples:

case 0 => ZIO.unit

ok

No exceptions, just following normal program flow.


case 1 => ZIO.fail("explicit failure")
//type ZIO[Any, String, Nothing]

caught failure explicit failure

using ZIO.fail we can throw a failure in the error channel. This doesn’t even have to be a subclass of Throwable.


case 2 => ZIO.attempt(throw new RuntimeException("failure from a caught exception"))
//type ZIO[Any, Throwable, Nothing]

caught failure java.lang.RuntimeException: failure from a caught exception

ZIO.attempt can be used to lift code that may throw an Exception, to put that Exception in the error channel


case 3 => ZIO.die(new RuntimeException("explicit defect"))
//type ZIO[Any, Nothing, Nothing]

caught defect java.lang.RuntimeException: explicit defect

ZIO.die can be used to throw a defect. These are unexpected exceptions, they don’t show in the error channel. They can’t be caught by catchAll, they require catchAllDefect


case 4 => ZIO.unit.map(_ => throw new RuntimeException("uncaught exception"))
//type ZIO[Any, Nothing, Nothing]

caught defect java.lang.RuntimeException: uncaught exception

When an exception is thrown in an unexpected place, like ZIO.succeed or map, it will be turned in a defect.


case 6 => ZIO.attempt(throw new StackOverflowError("Fatal error that cannot be caught."))
//type ZIO[Any, Throwable, Nothing]

**** WARNING ****
Catastrophic error encountered. Application not safely interrupted. Resources may be leaked. Check the logs for more details and consider overriding `Runtime.reportFatal` to capture context.
java.lang.StackOverflowError: Fatal error that cannot be caught.
	at exception.DangerousCode$.danger$$anonfun$7(DangerousCode.scala:13)

Descendants of VirtualMachineError, like StackOverflowError or OutOfMemoryError are called Fatal Errors.

They are so horrible, that they cannot be caught. The best we can hope for is to log them while the application shuts down.


case 5 => ZIO.fail(new RuntimeException("first exception")).ensuring(ZIO.die(new RuntimeException("second exception")))
//type ZIO[Any, RuntimeException, Nothing]

caught failure java.lang.RuntimeException: first exception

With ensuring it’s possible for multiple errors to occur. In the original block, then it will still run the code in the ensuring block, which cannot have a failure (it must be a ZIO[R, Nothing, A]), but it can throw a defect. We will only see the original failure like this.

To also see these secondary errors, we can use catchAllCause. To try this we’ll run the following application, with the same DangerousCode.danger function:

object CauseApp extends ZIOAppDefault {
  def run = for {
    _ <- zio.Console.printLine("Example app start")
    _ <- ZIO.foreach(Range(0, Int.MaxValue))(safe)
    _ <- zio.Console.printLine("Example app end")
  } yield ()

  def safe(i: Int): Task[Unit] =
    DangerousCode.danger(i)
      .catchAllCause(c => Console.printLine(s"caught cause $c"))
}

this results in this output:

Example app start
caught cause Fail(explicit failure,Stack trace for thread "zio-fiber-6":
at exception.DangerousCode.danger(DangerousCode.scala:8)
at exception.CauseApp.safe(CauseApp.scala:14)
at exception.CauseApp.run(CauseApp.scala:8)
at exception.CauseApp.run(CauseApp.scala:10))
caught cause Fail(java.lang.RuntimeException: failure from a caught exception,Stack trace for thread "zio-fiber-6":
at exception.DangerousCode.danger(DangerousCode.scala:9)
at exception.CauseApp.safe(CauseApp.scala:14)
at exception.CauseApp.run(CauseApp.scala:8)
at exception.CauseApp.run(CauseApp.scala:10))
caught cause Die(java.lang.RuntimeException: explicit defect,Stack trace for thread "zio-fiber-6":
at exception.DangerousCode.danger(DangerousCode.scala:10)
at exception.CauseApp.safe(CauseApp.scala:14)
at exception.CauseApp.run(CauseApp.scala:8)
at exception.CauseApp.run(CauseApp.scala:10))
caught cause Die(java.lang.RuntimeException: uncaught exception,Stack trace for thread "zio-fiber-6":
at exception.DangerousCode.danger(DangerousCode.scala:11)
at exception.CauseApp.safe(CauseApp.scala:14)
at exception.CauseApp.run(CauseApp.scala:8)
at exception.CauseApp.run(CauseApp.scala:10))
caught cause Then(Fail(java.lang.RuntimeException: first exception,Stack trace for thread "zio-fiber-6":
at exception.DangerousCode.danger(DangerousCode.scala:12)
at exception.CauseApp.safe(CauseApp.scala:14)
at exception.CauseApp.run(CauseApp.scala:8)
at exception.CauseApp.run(CauseApp.scala:10)),Die(java.lang.RuntimeException: second exception,Stack trace for thread "zio-fiber-6":
at exception.DangerousCode.danger(DangerousCode.scala:12)
at exception.CauseApp.safe(CauseApp.scala:14)
at exception.CauseApp.run(CauseApp.scala:8)
at exception.CauseApp.run(CauseApp.scala:10)))
**** WARNING ****
Catastrophic error encountered. Application not safely interrupted. Resources may be leaked. Check the logs for more details and consider overriding `Runtime.reportFatal` to capture context.
java.lang.StackOverflowError: Fatal error that cannot be caught.
at exception.DangerousCode$.danger$$anonfun$7(DangerousCode.scala:13)
	at zio.ZIOCompanionVersionSpecific.attempt$$anonfun$1(ZIOCompanionVersionSpecific.scala:107)
at zio.internal.FiberRuntime.runLoop(FiberRuntime.scala:965)
at zio.internal.FiberRuntime.runLoop(FiberRuntime.scala:872)
at zio.internal.FiberRuntime.runLoop(FiberRuntime.scala:1063)
at zio.internal.FiberRuntime.evaluateEffect(FiberRuntime.scala:330)
at zio.internal.FiberRuntime.evaluateMessageWhileSuspended(FiberRuntime.scala:448)
at zio.internal.FiberRuntime.drainQueueOnCurrentThread(FiberRuntime.scala:223)
at zio.internal.FiberRuntime.run(FiberRuntime.scala:140)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
at java.base/java.lang.Thread.run(Thread.java:831)

Process finished with exit code 255

This will also give us the second exception and stacktraces. Perfect for some top-level logging.

The StackOverflowError cannot be caught by this either.

How to use this

If you’re dealing with a lot of legacy code; using a lot of ZIO.attempt, you may be tempted to always use ZIO[R, Throwable, A]. The problem with this is that you loose sight of what specific exceptions might be in that Throwable. This is comparable to Java, where everyone prefers RuntimeExceptions over checked exceptions.

In ZIO, especially with union types introduced in Scala 3, it is very convenient to return specific exceptions.

They can then be

  • retryed

  • handled with a fallback

  • ignored

  • changed into a defect

  • combined and sent up the stack

Consider this application with examples for all these

object CombineExceptionApp extends ZIOAppDefault {
  def run = (for {
        i <- getConfig
        _ <- writeOutput(i)
      } yield ()
    ).catchAllCause(c => Console.printLine(s"caught cause $c"))

  class ParseException(cause: Throwable) extends Exception(cause)

  def readFile(filename: String): ZIO[Any, IOException, String] = ZIO.succeed("file content")

  def writeFile(filename: String, content: String): ZIO[Any, IOException, Nothing] = ZIO.fail(IOException("cannot write"))

  //Here we wrap the NumberFormatException from toInt
  def parse(s: String): ZIO[Any, ParseException, Int] = ZIO.attempt(s.toInt).mapError(t => ParseException(t))

  //Here we combine 2 exception types to send them up the stack
  def readAndParse(filename: String): ZIO[Any, IOException | ParseException, Int] = for {
    content <- readFile(filename)
    parsed <- parse(content)
  } yield parsed

  //Here we silently ignore the exception.
  //In a real application, you probably want to log an exception before ignoring it.
  //And consider killing/restarting the application if logging doesn't work.
  def log(t: Throwable): ZIO[Any, Nothing, Unit] = Console.printLine(t.getMessage).ignore

  //Here we log the Exception, then fall back to a default result
  def getConfig: ZIO[Any, Nothing, Int] = readAndParse("config").tapError(log).orElseSucceed(0)

  //Here we try writing once more, then after that fails, change the failure into a defect.
  def writeOutput(i: Int): ZIO[Any, Nothing, Nothing] = writeFile("output", s"parsed: $i").tapError(log).retryN(1).orDie
}

Note that even though failures can be any type, it’s still easiest to make them descend from Throwable to be able to change them into a defect easily with orDie.

This has the problem that we need to decide if an exception can be handled higher up in the stack. This is likely to be not such a big problem if it’s the same codebase. Otherwise err on the side of giving too much exception info.

shadow-left