Exceptions in ZIO
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.