Skip to content
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

Make IO#onError consistent with ApplicativeError #4121

Conversation

lenguyenthanh
Copy link
Member

This also deprecates the old IO.onError function and replace it's usage with the new one.

Close #4058

@lenguyenthanh lenguyenthanh force-pushed the make-io-onError-consistent-with-applicative-error branch from f9ae95c to b4cd5e5 Compare August 20, 2024 15:56
@lenguyenthanh lenguyenthanh marked this pull request as draft August 20, 2024 16:13
As it requires to annotate anonymous function: The argument types
of an anonymous function must be fully known. (SLS 8.5)
@lenguyenthanh lenguyenthanh force-pushed the make-io-onError-consistent-with-applicative-error branch from 9aedbfe to 7825274 Compare August 20, 2024 17:15
@lenguyenthanh lenguyenthanh marked this pull request as ready for review August 20, 2024 17:36
@armanbilge armanbilge changed the title Make io on error consistent with applicative error Make IO#onError consistent with ApplicativeError Aug 20, 2024
@@ -588,9 +589,19 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] {
def onCancel(fin: IO[Unit]): IO[A] =
IO.OnCancel(this, fin)

@deprecated("Use onError with PartialFunction argument", "3.6.0")
def onError(f: Throwable => IO[Unit]): IO[A] =
handleErrorWith(t => f(t).voidError *> IO.raiseError(t))
Copy link
Member

@armanbilge armanbilge Aug 20, 2024

Choose a reason for hiding this comment

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

Can we delegate this to the other method?

Suggested change
handleErrorWith(t => f(t).voidError *> IO.raiseError(t))
onError(f: PartialFunction[Throwable, IO[Unit]])

Copy link
Member Author

Choose a reason for hiding this comment

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

mm, I'm not sure I understand, I think your suggestion shouldn't be compiled?
Or do you mean use the new onError?
btw, I'm about to go to bed, will think more and answer tomorrow.

Copy link
Member

Choose a reason for hiding this comment

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

I think your suggestion shouldn't be compiled? Or do you mean use the new onError?

Oops, I made a typo. But I think you are right, this won't compile as-is 🤔

Basically we should avoid to duplicate the onError implementation. We can share the implementation by delegating one onError to the other onError, by converting Function to PartialFunction or vice versa.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed this in f4f505c

Another way would be

-    handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit) *> IO.raiseError(t))
+    onError(t => pf.applyOrElse(t, (_: Throwable) => IO.unit))

but it feels awkward as We used a function that We just deprecated it.

@armanbilge armanbilge added this to the v3.6.0 milestone Aug 20, 2024
* Implements `ApplicativeError.onError`.
*/
def onError(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] =
handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit) *> IO.raiseError(t))
Copy link
Member

Choose a reason for hiding this comment

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

To match the other implementation we need this voidError. But this raises an interesting question about the semantics: if the onError callback raises an error, which error should be propagated, that error or the original one? Also the voidError completely discards the error without reporting it, which seems really weird 😕

Suggested change
handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit) *> IO.raiseError(t))
handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit).voidError *> IO.raiseError(t))

Copy link
Member

Choose a reason for hiding this comment

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

I think what we should do is extract this into a helper method.

IO.executionContext.flatMap(ec => IO(ec.reportFailure(t)))

Then instead of voidError we should redirect errors within the onError callback to that? And then re-raise the original error.

Copy link
Member Author

Choose a reason for hiding this comment

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

thanks @armanbilge, I'm completely missed that voidError. I added reportError helper method as your suggestion. I think this is a great improvement. The name is subjecting for bike-shedding. We can also use this reportError method in the two other places. I can do that in this pr.

Copy link
Member

Choose a reason for hiding this comment

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

I'm completely missed that voidError

Yes, I almost missed it as well. We should add a test to cover this case (what happens if onError callback raises an error), that would have helped us. Actually we might even consider adding this as a law to Cats, so that onError semantics are the same across all implementations.

We can also use this reportError method in the two other places. I can do that in this pr.

🚀 it looks good! I think name is reasonable and it is private internal anyway.

Copy link
Member Author

Choose a reason for hiding this comment

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

cool, I'll add a test case for in this first. And think about a law for cats later on.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, actually, now I am hesitating 😅

The goal of this PR is to make IO#onError consistent with ApplicativeError#onError. If we use voidError/reportError then the semantics are inconsistent again with ApplicativeError 😕

https://github.com/typelevel/cats/blob/8e8724a29881a394bc39357a7f0cd21124573f30/core/src/main/scala/cats/ApplicativeError.scala#L261-L262

Unless we change it in Cats (and this may be too drastic of a change) I think we just have to let the callback's error propagate instead.

Copy link
Member Author

Choose a reason for hiding this comment

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

ahhhh, you're right. So, my first implemenaton was accidentally correct :p.

With this change, if the onError callback raises any error, We'll
report that error and then re-raise the original error.
Comment on lines 592 to 606
@deprecated("Use onError with PartialFunction argument", "3.6.0")
def onError(f: Throwable => IO[Unit]): IO[A] = {
val pf: PartialFunction[Throwable, IO[Unit]] = { case t => f(t) }
onError(pf)
}

/**
* Execute a callback on certain errors, then rethrow them. Any non matching error is rethrown
* as well.
*
* Implements `ApplicativeError.onError`.
*/
def onError(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] =
handleErrorWith(t =>
pf.applyOrElse(t, (_: Throwable) => IO.unit).reportError *> IO.raiseError(t))
Copy link
Member

Choose a reason for hiding this comment

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

I guess we should use reportError for the deprecated onError, but for the onError consistent with ApplicativeError we have to match the semantics defined in Cats.

Suggested change
@deprecated("Use onError with PartialFunction argument", "3.6.0")
def onError(f: Throwable => IO[Unit]): IO[A] = {
val pf: PartialFunction[Throwable, IO[Unit]] = { case t => f(t) }
onError(pf)
}
/**
* Execute a callback on certain errors, then rethrow them. Any non matching error is rethrown
* as well.
*
* Implements `ApplicativeError.onError`.
*/
def onError(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] =
handleErrorWith(t =>
pf.applyOrElse(t, (_: Throwable) => IO.unit).reportError *> IO.raiseError(t))
@deprecated("Use onError with PartialFunction argument", "3.6.0")
def onError(f: Throwable => IO[Unit]): IO[A] = {
val pf: PartialFunction[Throwable, IO[Unit]] = { case t => f(t).reportError }
onError(pf)
}
/**
* Execute a callback on certain errors, then rethrow them. Any non matching error is rethrown
* as well.
*
* Implements `ApplicativeError.onError`.
*/
def onError(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] =
handleErrorWith(t =>
pf.applyOrElse(t, (_: Throwable) => IO.unit)*> IO.raiseError(t))

Copy link
Member Author

Choose a reason for hiding this comment

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

fixed in: 868451b

For the ApplicativeError.onError method, We let the callback's
error propagate to match with cats's ApplicativeError semantic.
For the old onError, We report callback's error and raise the
original error instead.
armanbilge
armanbilge previously approved these changes Aug 22, 2024
Copy link
Member

@armanbilge armanbilge left a comment

Choose a reason for hiding this comment

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

Nice, this looks good to me. It turns out the old onError was inconsistent both in signature and in behavior, so makes sense to deprecate.

@armanbilge
Copy link
Member

@lenguyenthanh if you want a small follow-up PR, I think it would be worthwhile to add a law to Cats testing this behavior when onError raises an error.

@lenguyenthanh
Copy link
Member Author

lenguyenthanh commented Aug 23, 2024

thanks @armanbilge for your guidance, It helped me a lot!

For the onError laws, iiuc, We already have it: https://github.com/typelevel/cats/blob/8e8724a29881a394bc39357a7f0cd21124573f30/laws/src/main/scala/cats/laws/ApplicativeErrorLaws.scala#L70-L71C63

Not sure why We didn't catch it here, maybe because of the generator?

@armanbilge
Copy link
Member

For the onError laws, iiuc, We already have it:

@lenguyenthanh ah yes, you're right. Even though there is no explicit test for this case, it should be covered by the generator.

Not sure why We didn't catch it here, maybe because of the generator?

I was confused about this as well, but I think I know why. We actually need to do one more thing in your PR: we need to add an onError override to the IOAsync instances, because otherwise your implementation is actually not being law tested.

private[this] final class IOAsync extends kernel.Async[IO] with StackSafeMonad[IO] {

Actually, we should make sure that every custom method on IO is overridden in the IOAsync instances. If we had done this at the beginning for onError we would have realized that the signature and implementation are wrong.

@lenguyenthanh
Copy link
Member Author

If we had done this at the beginning for onError we would have realized that the signature and implementation are wrong.

@armanbilge added the onError override to IOAsync. But it seems tests are passed even with the old onError method. I guess it's because a partial function is a function and our test suite doesn't work well with throwable 🤷. I'll dig more into this after #3757

@armanbilge
Copy link
Member

But it seems tests are passed even with the old onError method.

Hmm, if I restore the semantics mismatch/bug, then the laws fail. So seems to be working.

diff --git a/core/shared/src/main/scala/cats/effect/IO.scala b/core/shared/src/main/scala/cats/effect/IO.scala
index ee3a9da1f..408b2a5e9 100644
--- a/core/shared/src/main/scala/cats/effect/IO.scala
+++ b/core/shared/src/main/scala/cats/effect/IO.scala
@@ -596,7 +596,7 @@ sealed abstract class IO[+A] private () extends IOPlatform[A] {
    * Implements `ApplicativeError.onError`.
    */
   def onError(pf: PartialFunction[Throwable, IO[Unit]]): IO[A] =
-    handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit) *> IO.raiseError(t))
+    handleErrorWith(t => pf.applyOrElse(t, (_: Throwable) => IO.unit).voidError *> IO.raiseError(t))
 
   /**
    * Like `Parallel.parProductL`
failing seed for async.applicativeError onError raise is 7OQiZWdn0skINxhWMBdm5dKBPdF49wcLmELPfFNLY_D=
[error] x async.applicativeError onError raise
[error]  Falsified after 4 passed tests.
[error]  > Labels of failing property:Expected: IO(...)
[error]  Received: IO(...)> ARG_0: IO(...)
[error]  > ARG_1: java.lang.Exception
[error]  > ARG_2: IO(...)
[error]  The seed is 7OQiZWdn0skINxhWMBdm5dKBPdF49wcLmELPfFNLY_D=

@armanbilge armanbilge merged commit dab9d23 into typelevel:series/3.x Aug 28, 2024
29 of 33 checks passed
@lenguyenthanh lenguyenthanh deleted the make-io-onError-consistent-with-applicative-error branch August 28, 2024 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

IO.onError is inconsistent with ApplicativeError
2 participants