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

Error handle without panic #1042

Merged
merged 36 commits into from
Feb 3, 2025

Conversation

johnhungerford
Copy link
Contributor

@johnhungerford johnhungerford commented Jan 21, 2025

  • Result.Partial[E, A] type alias for Success[A] | Failure[E]
  • Abort.runPartial to run to Result.Partial and not handle Panic cases
  • Abort.fold to handle either all three cases or (overloaded) just Success and Failure.
  • partialResult combinator wrapping Abort.runPartial
  • foldAbort combinator wrapping Abort.fold
  • fold and partialResult on ForAbortOps to handle a subset of the Abort union.

@johnhungerford
Copy link
Contributor Author

Closes #1014

@johnhungerford johnhungerford marked this pull request as draft January 21, 2025 20:58
@johnhungerford
Copy link
Contributor Author

Need to add tests for Result.Partial API now that it's an opaque type

@fwbrasil
Copy link
Collaborator

fwbrasil commented Jan 21, 2025

FYI there's something going on in CI making builds fail. I thought it could be the build caching I had introduced but I reverted it and it's still happening. I'm looking into it.

@johnhungerford johnhungerford marked this pull request as ready for review January 22, 2025 03:06
reduce: Reducible[Abort[ER]]
): Result.Partial[E, A] < (S & reduce.SReduced) =
Abort.runWith[E](v):
case Panic(thr) => throw thr
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't seem correct. The returned computation should have a pending Abort[Nothing] given that panics aren't handled. I think we can remove the Reducible evidence and return Result.Partial[E, A] < (S & Abort[ER]), which will infer to Abort[Nothing] if it's the only failure pending.

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've added some additional ...OrThrow methods to handle completely and throwing in the case of Panic.

@@ -267,7 +267,7 @@ class AbortsTest extends Test:
"converts matching Panic to Fail" in {
val ex = new RuntimeException("Test exception")
val result = Abort.run[RuntimeException](Abort.panic(ex)).eval
assert(result == Result.panic(ex))
assert(result == Result.Failure(ex))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test did not assert what it purported to be testing. Note that I made changes to Abort.run (now Abort.runWith) to produce the behavior this test seems to want. This is now producing a bunch of failed kyo-core tests, however.

Do we actually want Panic to be lifted to Failure or not? If yes, I'll go and fix the other tests.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think I had introduced this behavior initially and changed it later based on @hearnadam's feedback but I forgot to update the test. We should probably remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok -- that's where I was leaning after making the changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we want to continue to convert caught exceptions to Failure when they are subtypes? This doesn't seem to be tested, but it's implemented in Abort.run (now Abort.runWith). This behavior seems similarly problematic in that it means potentially handling failures from some other part of your application unexpectedly:

// Say you are given a program that gets some text, and it throws an unexpected (i.e., no Abort) `IOException` 
val effect1: String < Async = ???

// Now say you have an method to persist text, which expects to sometimes throw an `IOException`.
// Accordingly, it tracks these failures with `Abort`.
def persistValue(int: Int): Unit < (Async & Abort[IOException]) = ???

// Then you combine them retrieve and persist text
val effect2: Int < (Async & Abort[IOException]) = effect1.map(someMethod)

// Finally, you handle the error, assuming it's from persistence, not retrieval
val effect3 = Abort.fold[IOException[(effect2):
   case Result.Success(_) => Console.printLine("Successfully retrieved and persisted text")
   case Result.Failure(_) => Console.printLine("Failed to persist text")
   case Result.Panic(e) => Console.printLine(s"Failed to retrieve or persist text with unexpected exception: $e")

In the above scenario, effect3, when run, would report that it failed to persist the text when in cases where it in fact failed to retrieve the text.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I feel this is a reasonable behavior. If we don't catch exceptions in Abort operations, we would need to provide method variations that include catching since it's a common need. I normally use it to handle failures coming from other libraries for example. @hearnadam do you mind sharing your thoughts on this?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should avoid over-catching exceptions the user does not wrap. Obviously the machinery for effect handling may need to catch this for enriched traces, but I doubt we should be putting that into the 'expected' error channel.

Copy link
Collaborator

@fwbrasil fwbrasil Feb 2, 2025

Choose a reason for hiding this comment

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

The internal implementation could not catch, that's possible. My concern is the semantics for the user. I think this code not catching the exception would be surprising and inconvenient:

Abort.run[SomeException](someCodeMayThrowTheException)

// would have to become

Abort.run[SomeException](Abort.catching[SomeException](someCodeMayThrowTheException))

It seems your mental model is that Abort should be concerned mainly with expected failures? I'd say the majority of the users not used to type-level failure tracking would find the strictness unnecessary. Are there scenarios where it would be essential?

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 think the scenario I gave above is an telling case. While some users would probably want to lift thrown exceptions to failures, those who do not would not have any way to distinguish between expected exceptions and those of the same type thrown unexpectedly by some other part of their code base.

For the former user, we could add a method runCatching.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I still have trouble to see the value of such behavior considering the potential drawback in usability but I might be missing something. Can we follow up on in a new issue?

fl2: Flat[B],
fr: Frame
): B < (S & S1) =
Abort.foldOrThrow(onSuccess, onFail)(effect)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that in kyo-combinators, I'm throwing Panic exceptions by default for all error-handling methods that ignore panic when handling for the full Abort type. When using forAbort[E], these methods will leave Panic unhandled and eventually reduce to Abort[Nothing].

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it a usability decision? It'd be nice to keep a similar behavior to the Abort APIs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah usability. The point of this PR is to have a way to deal with Abort where you don't have to think about panic. Leaving an Abort[Nothing] in the pending intersection when you handle success and failure means you have to handle Abort a second time which is not great.

On the other hand, if you are only handling a single subtype within the Abort, you probably don't want to throw Panic exceptions. For this reason, I broke the Abort methods into two forms -- one that leaves Abort[Nothing] and one that throws (Abort.fold vs Abort.foldOrThrow, and Abort.runPartial and Abort.runPartialOrThrow). The ...OrThrow methods operate on only the whole Abort type rather than allowing you to specify a type. This is essentially the same behavior as kyo-combinators.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sounds reasonable. Something to keep in mind is that Abort[Nothing] is included in IO and Async but I think the compiler doesn't automatically de-duplicate the pending effect.

Copy link
Contributor Author

@johnhungerford johnhungerford Jan 31, 2025

Choose a reason for hiding this comment

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

Hmm I didn't realize that. In that case, it probably makes more sense to have all of these methods not throw and just handle to Abort[Nothing] and to add separate ...OrThrow methods. Most cases where a user will want to handle Abort will be in conjunction with Async or IO anyways, and this could result in repeatedly throwing and catching the same exceptions, which seems bad.

I'll make the combinators work the same way as Abort: fold/foldOrCatch and partialResult/partialResultOrThrow.

I'll make orPanic reduce Abort[X] to Abort[Nothing], and I'll add an orThrow method to eliminate Abort entirely by throwing exceptions.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Cool! If we're able to keep similar semantics, it'd be easier for people to start with simplified APIs and eventually migrate to rich combinators as they become more proficient

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. I feel happy with the current API.

Copy link
Collaborator

@fwbrasil fwbrasil left a comment

Choose a reason for hiding this comment

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

This is a great improvement! Thanks for making the changes across the modules :)

@fwbrasil
Copy link
Collaborator

fwbrasil commented Feb 1, 2025

Let's wait for @hearnadam's feedback to merge

Copy link
Collaborator

@hearnadam hearnadam 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 very happy to see these methods being added, though I'm not in love with much of the naming. We can always change them later... but I don't like doing so 😅

@@ -405,7 +512,7 @@ extension [A, S, E](effect: A < (Abort[E] & S))
handled.map((v: Result[E, A]) => Abort.get(v.swap))
end swapAbort

/** Catches any Aborts and panics instead
/** Converts any Aborts to Panic, wrapping non-Throwable Failures in PanicException
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/** Converts any Aborts to Panic, wrapping non-Throwable Failures in PanicException
/** Converts all Aborts to Panic, wrapping non-Throwable Failures in PanicException

case Result.Success(v) => v
case Result.Failure(thr: Throwable) => Abort.panic(thr).asInstanceOf[Nothing < Any]
case Result.Failure(thr: Throwable) => Abort.panic(thr)
case Result.Failure(other) => Abort.panic(PanicException(other)).asInstanceOf[Nothing < Any]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
case Result.Failure(other) => Abort.panic(PanicException(other)).asInstanceOf[Nothing < Any]
case Result.Failure(other) => Abort.panic(PanicException(other))

@@ -267,7 +267,7 @@ class AbortsTest extends Test:
"converts matching Panic to Fail" in {
val ex = new RuntimeException("Test exception")
val result = Abort.run[RuntimeException](Abort.panic(ex)).eval
assert(result == Result.panic(ex))
assert(result == Result.Failure(ex))
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should avoid over-catching exceptions the user does not wrap. Obviously the machinery for effect handling may need to catch this for enriched traces, but I doubt we should be putting that into the 'expected' error channel.

* @return
* An Either with Left containing the error or exception, and Right containing the successful value
*/
def toEitherPartial: Either[E, A] =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do the methods need to have distinct names?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, because Partial is a subtype of Result so the methods conflict otherwise. fold ought to work without renaming it since the signature is different, but I couldn't get it to work.

* @tparam A
* The type of the successful value
*/
opaque type Partial[+E, +A] >: Success[A] | Failure[E] <: Result[E, A] = Success[A] | Failure[E]
Copy link
Collaborator

Choose a reason for hiding this comment

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

My only real hesitation is that a Partial result sounds almost the opposite of what this is. This is the Expected result, but I don't think that's great either...

Copy link
Collaborator

Choose a reason for hiding this comment

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

Partial made sense to me given that it partially represents a Result but I agree it doesn't communicate much meaning. I can't think of good suggestions. Maybe something like NoPanic or WithoutPanic?

ct: SafeClassTag[E],
reduce: Reducible[Abort[ER]]
): Result[E, A] < (S & reduce.SReduced) =
runWith[E](v)(identity)
end apply
end RunOps

/** Runs an Abort effect. This operation handles the Abort effect, converting it into a Result type.
*/
inline def run[E]: RunOps[E] = RunOps(())
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm tempted to suggest that run should return Partial by default. If we don't expect users to manipulate panics that is...

run & runAll?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If that's the case, then really what is now called Partial should be called Result, and what is now called Result should be Result.OrPanic or something like that. (That would be my preference, FWIW, given how I tend to use Abort.)

Copy link
Collaborator

Choose a reason for hiding this comment

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

What would be the behavior of Result.flatMap in case there's a failure during transformation? It seems the only option is throwing if panics aren't represented

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah good point. Presumably we'd have a flatMapAll for Result.OrPanic and restrict flatMap to the narrower Result. This is all on the premise that users generally won't be dealing with the Panic case.

@@ -269,6 +271,13 @@ object Result:
throw exception
end Panic

/** Provides extension methods for Result type */
extension [A](self: Success[A])
def successValue: A = self match
Copy link
Collaborator

Choose a reason for hiding this comment

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

is value taken?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's an extension on Result that returns Maybe[A].

* Function to apply if the Result is a Success
* @return
* The result of applying the appropriate function
*/
inline def foldError[B](ifError: Error[E] => B)(inline ifSuccess: A => B): B =
inline def foldError[B](inline onSuccess: A => B, ifError: Error[E] => B): B =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps I missed a comment, but doesn't this make the inference worse?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change was made to make all the fold... methods more consistent. fold is not curried now and begins with onSuccess so that it can be overloaded to include or exclude onPanic. I haven't noticed any issues with type inference in the tests at least, but maybe I'm missing something?

@fwbrasil
Copy link
Collaborator

fwbrasil commented Feb 3, 2025

@johnhungerford @hearnadam are we ok to merge this?

@hearnadam hearnadam merged commit 4c922cc into getkyo:main Feb 3, 2025
3 checks passed
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.

3 participants