I am attempting to pass some data (an event) through a program composed out of smaller IOs. I need to run a computation (which can throw an exception) based on the event, then report out on what happened including the original event in the report.
This nominally seems straightforward but I'm observing an unexpected behavior (from my view, which may be naive!).
My approach is to run the computation, then use attempt / redeem to convert any thrown exceptions. When I use attempt (or redeem, which uses attempt) "inside" a flatmap call in an IOApp, exceptions are not caught and crash the whole app.
If I put attempt / redeem at the "top level" of the app, the app functions as expected - exceptions are caught and converted to values.
I'm not sure why this is happening. How can I ensure that I can capture exceptions as values so I can handle them later?
import cats.effect.{IO, IOApp}
object ParallelExecutionWAttempt extends IOApp.Simple {
def run: IO[Unit] = mainWInnerRedeem
/** Example of a Main program with redeem placed "inside" the flatmap
*
* Expected / desired outcome is for any thrown exception to be captured as a value and handled
*
* What Actually Happens - the exception does not get converted to a value and crashes the whole App
* */
def mainWInnerRedeem: IO[Unit] =
getEventFromSource
.flatMap{
event =>
getEventHandler(event).redeem(ex => onFailure(ex, event), _ => onSuccess(event))
}
/** Main program with redeem as the last in the chain. Result is as expected - the exception is caught.
*
* Unfortunately, pushing to the outside means I can't use the event in the success and failure handlers
*/
def mainWOuterRedeem: IO[Unit] =
getEventFromSource.flatMap(getEventHandler)
.redeem(
ex => IO.println(s"Program Failed exception was $ex"),
_ => IO.println("Program was a Success!")
)
/** Simple Event family for demo */
trait Event
case class Event1(a: Int) extends Event
case class Event2(b: String) extends Event
/** Simple Event Source - constructs an event in an IO */
def getEventFromSource: IO[Event] = IO{Event1(1)}
/** Retrieves a handler for events */
def getEventHandler(event: Event): IO[Unit] = blowsUp(event)
/** Handler funcs for testing - one automatically throws an exception, the other does not */
def blowsUp(event: Event): IO[Unit] = throw new RuntimeException("I blew up!")
def successfulFunc(event: Event): IO[Unit] = IO{println("I don't blow up")}
/** Functions to process handler results - takes event as a param */
def onSuccess(event: Event): IO[Unit] = IO.println(s"Success the event was $event")
def onFailure(throwable: Throwable, event: Event): IO[Unit] = IO.println(s"Failed with $throwable! Event was $event")
}
Related - I have noticed that this happens in almost any context where the call to attempt / redeem is not at the top level (i.e. if I am running two computations in parallel - e.g. .parTupled(program1.attempt, program2.attempt)
will crash the app if either throws an exception.
Conceptual Note - Yes, there are other ways for me to pass the data through other methods (Reader, Kleislis, etc) those add a bit of overhead for what i'm trying to accomplish here
CodePudding user response:
If I understood you correctly, getEventHandler
throws?
If so then the .redeem
won't be called, because exception will interrupt that.
event =>
getEventHandler(event) // if this throws
.redeem(ex => onFailure(ex, event), _ => onSuccess(event)) // this isn't called
Because .flatMap
in IO catches exceptions thrown by the mapping in both cases exception is caugth by IO. but only the second example can redeem it.
You can fix this by deferring execuion of getEventHandler(event)
event =>
IO.defer(getEventHandler(event)) // IO.defer catches the exception
.redeem(ex => onFailure(ex, event), _ => onSuccess(event)) // letting this line handling it
If you want this mechanism for any effect, not only cats.effect.IO
, look for Sync[F].defer[A](thunk: => F[A])
. It takes your code as by-name param letting it be run lazily inside F
where exception can be caught.
As far as I know (could be wrong), no Cats Effect typeclass has it as its law that thrown Exception
should be caught in .map
or .flatMap
, etc, but cats.effect.IO
and monix.execution.Task
assumed that it would be safer to do this, so if you are throwing like that for an arbitrary F
, you are kind of in the unspecified behavior territory. Sync[F].defer
and Sync[F].delay
are methods which come to mind as ones that should handle such cases, but in general I would still use them to avoid throwing from methods that should return F altogether.