Error handling
In Ox, we propose distinguishing two kinds of errors:
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.
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
parandracewhen one of the computations failsretrying computations in
retrywhen they failending a
supervisedconcurrency 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
}