Error handling

In Ox, we propose distinguishing two kinds of errors:

  1. unrecoverable errors: bugs, catastrophic failures (e.g. out of memory) and conditions which require the current “processing unit” to be terminated. This might be handling of the current HTTP request (and returning a 500), handling of an incoming MQ message, or simply exiting a CLI. These are untyped on purpose, and signalled using exceptions. That way, implementing control flow based on the specific error type is discouraged.

  2. recoverable/”expected” errors: anticipated failures, where the code can take specific corrective action based on the error’s details. These are fully typed, represented as values - using Eithers, or as part of a custom data type.

Unrecoverable errors

Exceptions are always appropriately handled by computation combinators, such as the high-level concurrency operations par and race, as well as by scopes and streams.

The general rule for computation combinators is that using them should throw exactly the same exceptions, as if the provided code was executed without them. That is, no additional exceptions might be thrown, and no exceptions are swallowed. The only difference is that some exceptions might be added as suppressed (e.g. interrupted exceptions).

Some examples of exception handling in Ox include:

  • short-circuiting in par and race when one of the computations fails

  • retrying computations in retry when they fail

  • ending a supervised concurrency scope when a supervised fork fails

Exceptions can be handled using the try/catch/finally mechanism.

Note

An error which is unrecoverable at one level might become recoverable when caught at a higher level - however, only with the context that is accessible at the catch point.

Recoverable errors

Some of the functionalities provided by Ox also support recoverable (application-level) errors. Such errors are represented as values, e.g. the left side of an Either[MyError, MyResult]. They are not thrown, but returned from the computations which are orchestrated by Ox.

Ox must be made aware of how such recoverable errors are represented. This is done through an ErrorMode. Provided implementations include EitherMode[E] (where left sides of Eithers are used to represent errors), and UnionMode[E], where a union type of E and a successful value is used. Arbitrary user-provided implementations are possible as well.

Error modes can be used in supervisedError scopes, as well as in variants of the par, race, retry methods, and others.

Note

Representing recoverable errors as values might incur a syntax overhead, and might be less convenient in some cases. Moreover, all I/O libraries typically throw exceptions - to use them with errors-as-values, one would need to provide a wrapper which converts such exceptions to values, at the boundary where recovery becomes meaningful.

Boundary/break for Eithers

To streamline working with Either values, Ox provides a specialised version of the boundary/break mechanism.

Within a code block passed to either, it allows “unwrapping” Eithers using .ok(). The unwrapped value corresponds to the right side of the Either, which by convention represents successful computations. In case a failure is encountered (a left side of an Either), the computation is short-circuited, and the failure becomes the result.

For example:

import ox.either
import ox.either.ok

case class User()
case class Organization()
case class Assignment(user: User, org: Organization)

def lookupUser(id1: Int): Either[String, User] = ???
def lookupOrganization(id2: Int): Either[String, Organization] = ???

val result: Either[String, Assignment] = either:
  val user = lookupUser(1).ok()
  val org = lookupOrganization(2).ok()
  Assignment(user, org)

You can also use union types to accumulate different types of errors, e.g.:

import ox.either
import ox.either.ok

val v1: Either[Int, String] = ???
val v2: Either[Long, String] = ???

val result: Either[Int | Long, String] = either:
  v1.ok() ++ v2.ok()

Options can be unwrapped as well; the error type is then Unit:

import ox.either
import ox.either.ok

val v1: Option[String] = ???
val v2: Option[Int] = ???

val result: Either[Unit, String] = either:
  v1.ok() * v2.ok()

Finally, a forked computation, resulting in an Either, can be joined & unwrapped using a single ok() invocation:

import ox.{either, fork, Fork, supervised}
import ox.either.ok

val v1: Either[Int, String] = ???

supervised:
  val forkedResult: Fork[Either[Int, String]] = fork(either(v1.ok()))

  val result: Either[Int, String] = either:
    forkedResult.ok()

Failures can be reported using .fail(). For example (although a pattern match would be better in such a simple case):

import ox.either
import ox.either.{fail, ok}

val v1: Either[String, Int] = ???

val result: Either[String, Int] = either:
  if v1.ok() > 10 then 42 else "wrong".fail()

Converting from exceptions

An exception-throwing expression can be converted to an Either using the .catching[E] extension method (catches only non-fatal exceptions!):

import ox.either.catching

val userInput: Boolean = ???
val result: Either[IllegalArgumentException, Int] =
  (if userInput then 10 else throw new IllegalArgumentException("boom"))
    .catching[IllegalArgumentException]

Any try-catch blocks that you have in your code should be kept as small as possible, so that it’s possibly obvious where the errors might originate. Using .catching at the sites where exceptions are thrown helps keeps the syntax lean and enables pinpointing where exceptions might occur.

An alternative to an either block is an either.catchAll block which additionally catches any non-fatal exceptions that occur when evaluating the nested expression. Within the block, both .ok() and .fail() can be used. The error type within such block is fixed to Throwable:

import ox.either
import ox.either.ok

def doWork(): Either[Exception, Boolean] = ???

val result: Either[Throwable, String] = either.catchAll:
  if doWork().ok() then "ok" else throw new RuntimeException("not ok")

Converting to exceptions

For Either instances where the left-side is an exception, the right-value of an Either can be unwrapped using .orThrow. The exception on the left side is thrown if it is present:

import ox.either.orThrow

val v1: Either[Exception, Int] = Right(10)
assert(v1.orThrow == 10)

val v2: Either[Exception, Int] = Left(new RuntimeException("boom!"))
v2.orThrow // throws RuntimeException("boom!")

Nested either blocks

Either blocks cannot be nested in the same scope to prevent surprising failures after refactors. The .ok() combinator is typed using inference. Therefore, nesting of either: blocks can quickly lead to a scenario where due to a change in the return type of a method, another either: block will be selected by the .ok() combinator. This could lead to a change in execution semantics without a compile error. Consider:

import ox.either
import ox. either.*

def returnsEither: Either[String, Int] = ???

val outerResult: Either[Exception, Unit] = either:
  val innerResult: Either[String, Int] = either:
    val i = returnsEither.ok() // this would jump to innerResult on Left
    // ...
    i
  ()

Now, after a small refactor of returnsEither return type the returnsEither.ok() expression would still compile but instead of short-circuiting the inner either: block, it would immediately jump to the outer either: block on errors.

import ox.either
import ox.either.*

def returnsEither: Either[Exception, Int] = ???

val outerResult: Either[Exception, Unit] = either:
  val innerResult: Either[String, Int] = either:
    val i = returnsEither.ok() // this would jump to outerResult on Left now!
    // ...
    i
  ()

Proper way to solve this is to extract the inner either: block to a separate function:

import ox.either
import ox.either.*

def returnsEither: Either[String, Int] = ???

def inner(): Either[String, Int] = either:
  val i = returnsEither.ok() // this can only jump to either on the opening of this function
  i

val outerResult: Either[Exception, Unit] = either:
  val innerResult = inner()
  ()

After this change refactoring returnsEither to return Either[Exception, Int] would yield a compile error on returnsEither.ok().

Mapping errors

When an Either is used to represent errors, these can be mapped by using .left.map. This can be used to entirely transform the error type, or eliminate some errors in case e.g. a union type is used. For example:

val e: Either[IllegalArgumentException | String, String] = ???

val e2: Either[String, String] = e.left.map {
  case e: IllegalArgumentException => s"Illegal argument: ${e.getMessage}"
  case other: String => other
}