-
Notifications
You must be signed in to change notification settings - Fork 367
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 catching exceptions configurable. #1772
Make catching exceptions configurable. #1772
Conversation
Fixes yesodweb#1771 This is done by adding a function to Yesod typeclass which can match on any exception and tell the framework if it should rethrow or not. I used an overridable function because it seemed more flexible then a whitelist. A user can now for example choose to throw everything, or catch everything as easily. add docs bump
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pbrisbin would you be able to look this over and see if it addresses your needs?
-- the default 'defaultCatchBehavior' is to catch everything | ||
-- (even async), except for the | ||
-- 'Warp.ConnectionClosedByPeer' constructor. | ||
catchBehavior :: Proxy site -> SomeException -> CatchBehavior |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may be too limiting an API. We've often run into situations later where people say they want to be able to change behavior at runtime. What do you think of something like:
catchBehavior :: site -> SomeException -> IO CatchBehavior
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, we may want to change the default behavior back to what it used to be (not catching async exceptions).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, perhaps it's still sensible to handle most exceptions, even async ones in yesod. For example if a thread is killed or an allocation limit reached, my expectation would be that the error page is rendered by yesod, not by warp.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pbrisbin thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. Well this is tricky because I'm coming at it from a couple of different perspectives.
As a user of Yesod on behalf of my users, I'd agree that I'd prefer if the normal error handling and error page triggered on ThreadKilled
and AllocationLimitExceeded
. Because, why not?
As an operator of Yesod, I'm not sure if I'd hold that preference for "most async exceptions", because I think there are a lot of async exceptions that are normal in the context of handling web requests, don't ever reach or impact end users, and I don't want them inflating error metrics as if they had.
As someone familiar with the implementation inolved, I don't agree that I'd expect the normal error handling and error page triggered on these two exceptions in particular. In my head, the scope of handling a web request is
Warp
Wai
|----------|
| Yesod |
| My App |
|----------|
In the case of these two exceptions in particular, I'd expect everything I've boxed there to be dead, like kill -9
dead. And so I personally wouldn't expect any error recovery to be possible at that level for such exceptions.
Knowing what I've learned recently about async exceptions, I think I could safely apply this expectation to "most async exceptions". I think they all represent kill -9
-like messaging and so there should be no recovery.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so I guess for most users this default of catching everything except timeout & ConnectionClosedByPeer constructor is sensible, but as an operator you could still override this behavior? In other words, everyone is happy?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As long as we end up in a place where TimeoutError
behaves correctly, I'm happy. If we also end up in a place where it's easy for me to say "don't catch any async exceptions", I'm very happy.
I think "don't catch async exceptions" is a more sensible default, for the reasons I tried to describe above, but it's subjective and I don't mind if you go the other way as you're writing the code and Michael can choose to merge it. I just don't see either of us convincing the other, but I don't think we need to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a test to verify by default we don't catch Timeout
either: https://github.com/yesodweb/yesod/pull/1772/files#diff-bd8750d9a236376b742e82438d8e92aeeb76f1d55827fc85c21154c5c4e8f9a0R378
I think that's what you meant, right?
There is a rethrow async function as well (although not as well tested): https://github.com/yesodweb/yesod/pull/1772/files#diff-1fdc39d8a397f906d8f4f69a087048f3432df0b1fd2238ee5459ff1e89bb0edfR655
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think "don't catch async exceptions" is a more sensible default
I think I lean towards this way of thinking, I was already nervous about the original approach since it violates what I generally recommend for async exception handling. I'd be tempted to call the previous change in behavior a bug (since it silently broke other people's code) and go back to the original default, still exposing this configuration point.
To fully flesh out the thinking here, I see four points in the design space for defaults:
- Rethrow all async exceptions (original behavior)
- Rethrow a specific list of async exceptions (current PR)
- Catch a specific list of async exceptions (not proposed yet)
- Catch all async exceptions (current behavior)
I'm not convinced (2) is a good compromise point, since including Timeout may seem like a good idea here, but honestly seems arbitrary to me. It's entirely possible there's someone else out there using a different async exception for middlewares that we're not aware of.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
both choices are arbitrary, in anycase I'll rewrite it to the 'rethrow async exception' behavior as default. having this configurable is good enough for me.
yesod-core/src/Yesod/Core/Types.hs
Outdated
@@ -169,6 +169,13 @@ newtype WaiSubsite = WaiSubsite { runWaiSubsite :: W.Application } | |||
-- @since 1.4.34 | |||
newtype WaiSubsiteWithAuth = WaiSubsiteWithAuth { runWaiSubsiteWithAuth :: W.Application } | |||
|
|||
-- | @since 1.6.23.2 | |||
data CatchBehavior = Rethrow -- ^ Rethrow an exception and let the webserver deal with it (usually warp) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what other behaviors may be relevant in the future, but we may want other possibilities than just rethrow and catch (e.g., "wrap up with this function"). Providing an abstract type and smart constructors would allow for future extensibility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have very strong opinions on the implementation, this seems to offer a way for me to say "don't catch async exceptions", which would be great.
I do have some questions, for my own curiosity:
Is there really value in restricting the possibilities to the two-constructor CatchBehavior
enumeration, rather than some fully open SomeException -> m ()
that the user could make do whatever they want?
I see "(even async), except for the 'Warp.ConnectionClosedByPeer' constructor", and I would humbly suggest TimeoutError
be included in any such lists, as that was a very clear exception not to catch no matter what.
I'm a little curious how this approach compares to using the existing yesodMiddleware
hook. That strikes me as an existing hook that is a perfectly reasonable place to define this behavior.
For example, if we want the default behavior to not catch, a user could add catching by:
instance Yesod MyApp where
yesodMiddleware = alsoCatchAsyncMiddleware . defaultYesodMiddleware
alsoCatchAsyncMiddleware h = h `EUnsafe.catch` \ex ->
errorHandler $InternalError $ pack $ displayException ex
Or if we want the default behavior to be to catch, a user could get not catching by:
instance Yesod MyApp where
yesodMiddleware = defaultYesodMiddlewareNoAsyncCatch
defaultYesodMiddleware = alsoCatchAsyncMiddleware . defaultYesodMiddlewareNoAsyncCatch
All naming TBD of course.
Is there something about that that doesn't work for the "please catch async and render error page" or "please don't catch async" use-cases?
how would this work then? if the callback doesn't throw an exception we should let yesod do the error rendering? It's not very obvious to me from just the type signature.
Yes this is a good idea. Sorry I misunderstood why at first but now I realize the timeout function is supposed to catch this.
This would work, but I think by default yesod should handle exceptions properly, that is to say render those with a 500 screen within yesod, except when the user doesn't want this to happen, which is why I also agree it should be configurable. |
add comments for this nonobvious test
f2bffcb
to
964fa0d
Compare
Everything's good from my end, but just to reply to your questions:
Yeah I was being vague, because I don't know exactly how it might work. I just know that your original PR (basically) replaced an class Yesod where
-- | By default we catch all exceptions to an error page, replace this with
-- 'UnliftIO.Exceptions.catch' to only catch synchronous exceptions
catch = EUnsafe.catch But I know this probably won't work because
Yeah, my example showed it with either default behavior. I was just curious if I was missing something that meant using |
Given the fact that everything involved is a catchHandlerExceptions :: site -> IO a -> (SomeException -> IO a) -> IO a |
By default the one from unliftIO is used.
rename catchBehvaior -> catchHandlerExceptions rename shouldCatch -> catchHanlderExceptions
I got rid of catchbehavior and now use the provided catch function as suggested. I removed all the code and tests involved with pattern matching on the ConnectionClosedByPeer constructor and timeout type. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like this implementation! Thanks for working through it.
Co-authored-by: patrick brisbin <pbrisbin@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks!
Fixes #1771
This is done by adding a function to Yesod
typeclass which can match on any exception
and tell the framework if it should rethrow
or not.
I used an overridable function because it seemed
more flexible then a whitelist.
A user can now for example choose to throw
everything, or catch everything as easily.
Before submitting your PR, check that you've:
@since
declarations to the Haddocks for new, public APIsAfter submitting your PR: