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

Add tapErrorZIO and tapErrorCauseZIO to Route and Routes #2755

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

tjarvstrand
Copy link

@tjarvstrand tjarvstrand commented Mar 30, 2024

Useful for things like error logging or reporting to external services such as Sentry.

@codecov-commenter
Copy link

codecov-commenter commented Mar 30, 2024

Codecov Report

Attention: Patch coverage is 0% with 10 lines in your changes are missing coverage. Please review.

Project coverage is 64.34%. Comparing base (709b459) to head (006b320).

Files Patch % Lines
...io-http/shared/src/main/scala/zio/http/Route.scala 0.00% 8 Missing ⚠️
...o-http/shared/src/main/scala/zio/http/Routes.scala 0.00% 2 Missing ⚠️

❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2755      +/-   ##
==========================================
- Coverage   64.42%   64.34%   -0.08%     
==========================================
  Files         148      148              
  Lines        8668     8678      +10     
  Branches     1589     1545      -44     
==========================================
  Hits         5584     5584              
- Misses       3084     3094      +10     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

self match {
case Provided(route, env) => Provided(route.tapErrorCauseZIO(f), env)
case Augmented(route, aspect) => Augmented(route.tapErrorCauseZIO(f), aspect)
case Handled(routePattern, handler, location) => Handled(routePattern, handler, location)
Copy link
Contributor

Choose a reason for hiding this comment

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

iirc, ensures handled that the typed error is handled. But a cause (defect) might still happen. So I think there should be a tap on the handler here.

Copy link
Author

@tjarvstrand tjarvstrand Apr 8, 2024

Choose a reason for hiding this comment

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

That doesn't seem to work as the Handled.handler's failure type is Response whereas f's failure type is Err which is not necessarily a Response.

Copy link
Member

Choose a reason for hiding this comment

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

You can catch the Cause[Err], and if it contains an Err, then you rethrow it. But if it's really a Cause[Nothing], i.e. pure defect, without an Err inside it, then you can allow that to be caught by f, since f can handle those (Cause[Nothing] is a subtype of Cause[Err]).

Copy link
Member

Choose a reason for hiding this comment

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

@tjarvstrand This does need to be fixed, and then will be good to merge!

Copy link
Author

Choose a reason for hiding this comment

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

Sorry, busy week! :/

I've been struggling a bit with the error case of f which, unless I'm mistaken, must be a Response. Does something like this make sense?

/**
   * Effectfully peeks at the unhandled failure cause of this Route.
   */
  final def tapErrorCauseZIO[Err1 >: Err](
    f: Cause[Err] => ZIO[Any, Err1, Any],
  )(implicit trace: Trace): Route[Env, Err1] =
    self match {
      case Provided(route, env)                        => Provided(route.tapErrorCauseZIO(f), env)
      case Augmented(route, aspect)                    => Augmented(route.tapErrorCauseZIO(f), aspect)
      case Handled(routePattern, handler, location)    =>
        Handled(
          routePattern,
          handler.tapErrorCauseZIO {
            case cause0: Cause[Err] => f(cause0).catchAllCause(cause => ZIO.fail(Response.fromCause(cause)))
            case _                  => ZIO.unit
          },
          location,
        )
      case Unhandled(rpm, handler, zippable, location) =>
        Unhandled(rpm, handler.tapErrorCauseZIO(f), zippable, location)
    }

Copy link
Contributor

@guersam guersam Jul 3, 2024

Choose a reason for hiding this comment

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

@tjarvstrand What I meant originally was we don't need pattern matching to determine if it's Cause[Nothing] or not, according to John's previous comment.

You can catch the Cause[Err], and if it contains an Err, then you rethrow it. But if it's really a Cause[Nothing], i.e. pure defect, without an Err inside it, then you can allow that to be caught by f, since f can handle those (Cause[Nothing] is a subtype of Cause[Err]).

But now we want to let f handle failure cases as well when Err =:= Response, right?

I think there are two options:

  1. Extract the possible failure with something like Cause#failureOrCause, and do runtime type check for the failure case using .isInstanceOf[Response]. It's the simplest solution. EDIT: It seems irrelevant.

  2. Require an implicit zio.Tag[Err] or scala.reflect.ClassTag[Err] and check its runtime class.

  3. Add an implicit evidence like this:

    sealed trait IsResponse[+A] { def isResponse: Boolean }
    object IsResponse {
      implicit val response: IsResponse[Response] = new IsResponse[Response] { isResponse = true }
    
      // to avoid unnecessary allocation
      private val otherInstance: IsResponse[Nothing] = new IsResponse[Nothing] { isResponse = false }
      implicit def other: IsResponse[A] = otherInstance
    }

    The name IsResponse might cause confusion with Handler.IsRequest, which has the similar purpose but it's for compile time check only.

Copy link
Contributor

@guersam guersam Jul 3, 2024

Choose a reason for hiding this comment

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

By the way, Handled.handler is a handler that produces another handler (code), so checking handler's failure won't achieve what you want. You need handler.map(_.tapErrorCauseZIO(...)) to catch the Response failure.

Copy link
Author

Choose a reason for hiding this comment

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

Thanks a lot for your comments!

If I understand it correctly, both alternative 2 and 3 would require a call to asInstanceOf, since neither of them provide a type level proof that Err <: Response. Is that correct?

By the way, Handled.handler is a handler that produces another handler (code), so checking handler's failure won't achieve what you want. You need handler.map(_.tapErrorCauseZIO(...)) to catch the Response failure.

Aha, I missed that! Thanks!

Copy link
Author

Choose a reason for hiding this comment

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

Alternative 2 sadly doesn't work since ClassTag is invariant: Covariant type Err occurs in invariant position in type ClassTag[Err] of value ct.

I gave alternative 3 a shot instead, but I had to make it contravariant rather than covariant.

Copy link
Contributor

@guersam guersam Jul 4, 2024

Choose a reason for hiding this comment

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

You can't directly summon ClassTag[Err] because it's invariant. Instead, introduce a new type variable like Err1.

Check out ZIO#refinedToOrDie for an example:

def refineToOrDie[E1 <: E: ClassTag](implicit ev: CanFail[E], trace: Trace): ZIO[R, E1, A] =
  self.refineOrDie { case e: E1 => e }

@tjarvstrand tjarvstrand force-pushed the routes-tap-error-cause-zio branch 2 times, most recently from 1f6c5e0 to 5589573 Compare July 3, 2024 08:55
Handled(
routePattern,
handler.map(_.tapErrorCauseZIO {
case err: Fail[_] if ev.isResponse =>
Copy link
Contributor

Choose a reason for hiding this comment

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

If ev.IsResponse == false we don't need handler.map(...) at all.

Copy link
Author

Choose a reason for hiding this comment

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

True!

routePattern,
handler.map(_.tapErrorCauseZIO {
case cause0 if ev.isResponse =>
f(cause0.asInstanceOf[Cause[Err]]).catchAllCause(cause => ZIO.fail(Response.fromCause(cause)))
Copy link
Contributor

@guersam guersam Jul 3, 2024

Choose a reason for hiding this comment

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

If the cause is not a failure, it can be passed to f even if Err =!= Response as John pointed out in the previous comment I quoted.

Copy link
Author

Choose a reason for hiding this comment

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

Ah, yeah, sorry I misread that one!

Copy link
Contributor

Choose a reason for hiding this comment

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

Same here about the .catchAllCause

@@ -489,4 +537,16 @@ object Route {
}
}

sealed trait IsResponseCauseHandler[-A] { def isResponse: Boolean }
Copy link
Contributor

Choose a reason for hiding this comment

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

The most type evidences starting with Is tend provide the implicit instance only and if only the type.

As it always provides the implicit value, maybe we need another name to avoid confusion.

Possible alternatives:

  • DetermineResponse
  • CheckResponse
  • ResponseTypeChecker
  • ...

I think CauseHandler is somewhat misleading as this type is not directly related to handler.

Copy link
Author

Choose a reason for hiding this comment

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

Good point. Changing it to CheckResponse

@tjarvstrand
Copy link
Author

Thanks for the feedback!

@tjarvstrand tjarvstrand force-pushed the routes-tap-error-cause-zio branch 3 times, most recently from cd742b1 to 933ec35 Compare July 4, 2024 11:32
routePattern,
if (ev.isResponse) {
handler.map(_.tapErrorCauseZIO {
case err: Fail[_] if ev.isResponse =>
Copy link
Contributor

Choose a reason for hiding this comment

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

Now we don't need this pattern guard :)

Copy link
Author

Choose a reason for hiding this comment

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

Removed!

if (ev.isResponse) {
handler.map(_.tapErrorCauseZIO {
case err: Fail[_] if ev.isResponse =>
f(err.value.asInstanceOf[Err]).catchAllCause(cause => ZIO.fail(Response.fromCause(cause)))
Copy link
Contributor

Choose a reason for hiding this comment

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

Now I doubt this .catchAllCause(cause => ZIO.fail(Response.fromCause(cause))) is necessary, because it may cause unexpected behavior when there is a global error handler, say catching all defects and produces a formatted error response.

As we're sure that Err =:= Response, just '.asInstance[...]' can be enough.

Copy link
Author

Choose a reason for hiding this comment

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

I think we do need the catchAllCause since we don't know that Err1 <: Response

routePattern,
handler.map(_.tapErrorCauseZIO {
case cause0 if ev.isResponse =>
f(cause0.asInstanceOf[Cause[Err]]).catchAllCause(cause => ZIO.fail(Response.fromCause(cause)))
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here about the .catchAllCause

case _: Fail[_] =>
ZIO.unit
case cause0 =>
f(cause0.asInstanceOf[Cause[Nothing]]).catchAllCause(cause => ZIO.fail(Response.fromCause(cause)))
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here about the .catchAllCause.

@987Nabil
Copy link
Contributor

@tjarvstrand Could you handle the review comments? I'd like to get this in for 3.0

@tjarvstrand
Copy link
Author

The tests seem to fail on something unrelated to my changes. Not sure if it's a fluke but I don't seem to be allowed to restart the workflow.

@987Nabil
Copy link
Contributor

I updated your branch. Let's see if it builds

case Handled(routePattern, handler, location) =>
Handled(
routePattern,
if (ev.isResponse) {
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 always true. Handled has a fixed error type of Nothing and will pick up the implicit val response: CheckResponse[Response]

Handled Routes are by definition routes without error. This should just returned the route without any modification.
The becomes clear when substituting the types. Because f is Nothing => ZIO[Any, Err1, Any]

Handled(
routePattern,
handler.map(_.tapErrorCauseZIO {
case cause0 if ev.isResponse =>
Copy link
Contributor

Choose a reason for hiding this comment

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

ev.isResponse is always true. Also the concrete type of f here is Cause[Nothing] => ZIO[Any, Err1, Any]

@987Nabil
Copy link
Contributor

987Nabil commented Sep 5, 2024

Please add some tests. For example adding a log output and use the test logger to find the message you logged. Or use a promise. Or whatever comes to your mind :)

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.

5 participants