8000 add support for catching failures only, without defects by somdoron · Pull Request #9488 · zio/zio · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

add support for catching failures only, without defects #9488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: series/2.x
Choose a base branch
from

Conversation

somdoron
Copy link
Contributor

fixes #9485.

ZIO 2.0 removed zio-logging logThrowable, ZIO 2.0 can only log causes. When one want to log an error, calling catchAll or tapError is not enough, because those cannot be logged. Therefore one must use catchAllCause or tapErrorCause, however those catch defects as well, which usually when recovering you don't actually want to catch. Currently there is no way to catch errors as cause without the defects.

This PR adds new variants to allow catching errors as cause without defects, which would allow them to be logged.

Copy link
Contributor
@kyri-petrou kyri-petrou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really appreciate this contribution, although I'm a bit sceptical because I feel like it will make things more confusing around effectful error handling.

I think the method signature should be changed to Cause[E] => ... instead of Cause.Fail[E] => .... However, doing so will definitely make things more conf 8000 using for users and zio-* library authors since we already have methods with similar signature / naming. If we're going to merge this in we need to make it extremely clear of the differences in the scaladoc of both the new methods but also the old ones

Comment on lines 436 to 438
final def catchSomeFailure[R1 <: R, E1 >: E, A1 >: A](
pf: PartialFunction[Cause.Fail[E], ZIO[R1, E1, A1]]
)(implicit trace: Trace): ZIO[R1, E1, A1] = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 335 to 337
* {{{
* effect.catchAllFailure(_ => backup())
* }}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be very confusing to users on why to use this method over catchAllZIO. We need to provide a better example in the scaladoc that differentiates, but also point to catchAllZIO when the users don't care about retrieving the full Cause of the failure (which is in most cases in user code)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You meant catchAllCause or catchAll?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation improved.

Comment on lines 2144 to 2149
/**
* Returns an effect that effectfully "peeks" at the failure of this effect.
* {{{
* readFile("data.json").tapError(logError(_))
* }}}
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be improved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

improved

@somdoron
Copy link
Contributor Author

Moving to Cause[E] removes the ability to access the actual error in an easy way.
With Cause.Fail[E], you can easily access the actual error.

For example, with Cause.Fail[E], you can:

zio.tapFailure(f => ZIO.logErrorCause(s"Error ${f.value.getMessage}", f))

However, you cannot do that with Cause[E].

I think that the current names and documentation are a bit confusing. For instance, tapErrorCause also taps defects, but this is not documented. The actual documentation reads:

Returns an effect that effectually "peeks" at the cause of the failure of this effect.

However, failure should not include defects.

Similarly, the name catchAllCause is problematic. Its documentation states:

Recovers from all errors with provided Cause.

But do errors include defects? The documentation is not clear about the fact that it also catches defects.

On the other hand, we have tapDefect and catchAllDefect, which are better documented.

The reason all of this came up is that we are in the process of upgrading to ZIO 2.0. Previously, we used catchAll and tapError to log errors using zio-logging's logThrowable. This is no longer possible; now, only logErrorCause works. Intuitively, we moved to catchAllCause. Great—now we can recover and log the error. However, we accidentally started recovering from defects as well, which we never intended.

This is why I think catchAllCause should be deprecated. Instead, users should either use catchFailure or, in extreme cases where recovery from both defects and failures is required, a new method with a more appropriate name should be introduced.

Interestingly, the catchAllDefect method includes a warning in its documentation:

'''WARNING''': There is no sensible way to recover from defects. This method should be used only at the boundary between ZIO and an external system, to transmit information on a defect for diagnostic or explanatory purposes.

A similar warning should also exist for catchAllCause or for the new variant.

@somdoron somdoron marked this pull request as draft January 22, 2025 13:30
@somdoron somdoron marked this pull request as ready for review January 23, 2025 12:55
@somdoron somdoron requested a review from kyri-petrou January 26, 2025 07:04
@somdoron
Copy link
Contributor Author

@hearnadam @kyri-petrou can someone please take a look at this again?

@quelgar
Copy link
Contributor
quelgar commented Mar 4, 2025

Just a note to say that everything @somdoron said matches my experience. The existing catchAllCause and its variants are not very useful in practice. We almost always just want to catch failures, but having the cause for logging can be incredibly useful. I've been using extension methods to achieve this in a slightly different way for some time:

implicit final class RichZIO[-R, +E, +A](private val self: ZIO[R, E, A]) extends AnyVal {

  final def catchAllFailureCause[R1, E1, A1 >: A](
      f: (E, Cause[E]) => ZIO[R1, E1, A1]
    )(
      implicit trace: Trace
    ): ZIO[R & R1, E1, A1] =
    self.catchAllCause { cause =>
      cause.failureOrCause match {
        case Left(e)  => f(e, cause)
        case Right(c) => ZIO.refailCause(c)
      }
    }

  final def catchSomeFailureCause[RR, EE >: E, AA >: A](
      pf: PartialFunction[(E, Cause[E]), ZIO[RR, EE, AA]]
    )(
      implicit trace: Trace
    ): ZIO[R with RR, EE, AA] =
    self.catchSomeCause { cause =>
      cause.failureOrCause match {
        case Left(e)  => pf.applyOrElse[(E, Cause[E]), ZIO[RR, EE, AA]]((e, cause), _ => ZIO.refailCause(cause))
        case Right(c) => ZIO.refailCause(c)
      }
    }

  final def tapFailureCause[R1, E1 >: E](
      f: (E, Cause[E]) => ZIO[R1, E1, Any]
    )(
      implicit trace: Trace
    ): ZIO[R & R1, E1, A] =
    self.tapErrorCause { cause =>
      cause.failureOption match {
        case Some(e) => f(e, cause)
        case None    => ZIO.refailCause(cause)
      }
    }
}

Copy link
Contributor
@kyri-petrou kyri-petrou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for taking this long to get back to you on this PR. As I mentioned previously I think we need to tread carefully with the naming / documentation of these methods. What are your thoughts on my suggestions?

* effect.catchAllFailure(c => ZIO.logErrorCause("failure", c))
* }}}
*/
final def catchAllFailure[R1 <: R, E1 >: E, A1 >: A](
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about naming this catchFailureCause instead? I think that would be a more accurate naming for this method right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the change, but I would this is creating a new convention, as for now, all of the overloads start with with catchAll:

catchAll
catchAllTrace
catchAllCause
catchAllDefect

* }
* }}}
*/
final def catchSomeFailure[R1 <: R, E1 >: E, A1 >: A](
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, it's a bit more verbose but what about catchSomeFailureCause?

* readFile("data.json").tapError(logError(_))
* }}}
*/
final def tapFailure[R1 <: R, E1 >: E](
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on tapFailureCause

@somdoron
Copy link
Contributor Author

@kyri-petrou I’ve applied the changes you suggested.

There’s one thing I’m not entirely happy with regarding this set of methods: the type is Cause rather than Cause.Fail. The issue is that it makes accessing the actual error more cumbersome when you want to handle it specifically while still logging it with the stack trace.

For example, you’d have to write something like:

zio.tapFailureCause(c => ZIO.logErrorCause(s"Error ${c.failureOption.value.getMessage}", c))

Which feels a bit clunky.

An alternative would be to use Cause.Fail as the type. That would limit access to just the first failure in the Cause, but I think that’s actually preferable — and typically what users expect, similar to how catchAll or tapError behave by only exposing the first error.

At the moment, the new methods provide access to all failures.

@kyri-petrou what do you think? @quelgar what are your thoughts on that?

@quelgar
Copy link
Contributor
quelgar commented Mar 27, 2025

@somdoron yes that makes sense to me. If all we got was a Cause, I'd be calling something like failureOption on it in 99% of cases anyway since the failure value is what I would want to pattern match on. For the rarer cases where we actually want all the causes, the full power of the existing catchAllCause is still available.

@somdoron somdoron requested a review from kyri-petrou March 28, 2025 12:53
Copy link
Contributor
@kyri-petrou kyri-petrou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really sorry for taking this long to get to review this. Every time I started reviewing it I went into a rabbit-hole with trying to figure out potential footguns for users, how to minimize API bloating and more importantly how to make these methods as maintainable as possible.

Frankly, I prefer this one over #9738 as #9738 brings too much API bloating around logging.

)(implicit ev: CanFail[E], trace: Trace): ZIO[R1, E1, A1] = {
def tryRescue(c: Cause[E]): ZIO[R1, E1, A1] =
if (c.isFailureOnly) {
c.find { case f: Cause.Fail[E] => f } match {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like pf.isDefinedAt should be used in this partial function no?

Comment on lines +360 to +362
{ case (e, stackTrace) =>
h(Cause.Fail(e, stackTrace))
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you were to change the signature, you could use failureOrCause here

* }}}
*/
final def catchFailureCause[R1 <: R, E2, A1 >: A](
h: Cause.Fail[E] => ZIO[R1, E2, A1]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think this should be Cause[E] => ZIO[R1, E2, A1]

* }}}
*/
final def catchSomeFailureCause[R1 <: R, E1 >: E, A1 >: A](
pf: PartialFunction[Cause.Fail[E], ZIO[R1, E1, A1]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, I think we need to change Cause.Fail[E] to Cause[E]

* }}}
*/
final def tapFailureCause[R1 <: R, E1 >: E](
f: Cause.Fail[E] => ZIO[R1, E1, Any]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

@@ -412,6 +415,28 @@ sealed abstract class Cause[+E] extends Product with Serializable { self =>
(causeOption, stackless) => causeOption.map(Stackless(_, stackless))
)

def keepFailures: Option[Cause[E]] =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you still think this one is needed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

No way to catch or tap Error as Cause without defects
3 participants
0