Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[DX] Better exception handling (as opposed to the current twig controller) #11752

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

Closed
NinoFloris opened this issue Aug 24, 2014 · 18 comments
Closed
Labels
DX DX = Developer eXperience (anything that improves the experience of using Symfony) TwigBundle

Comments

@NinoFloris
Copy link

Digest (TL;DR)

A small service that allows tagged controllers to handle the exception(s) they registered for, optionally bubbling via inheritance to the closest match in the inheritance chain.
To replace, but incorporate, the current inflexible twig controller.
For my reasoning, explanations, and implementation details read the long story.

Code can be found here: https://gist.github.com/NinoFloris/92523a34cdf17a084d4d

Would I do good to integrate this into the framework and issue a pull request?

Long story

I have written a small class because I was fed up with the current state of afairs around event based exception handling (kernel event exception).
I like to fire my exceptions from all over the place and let them be handled by an exception listener.
This eases my boilerplate code by a huge amount, e.g. not having to check for the request format (which response would the request like to receive, html, json, xml). Talking about that, see #10538.
I then don't have to repeat myself over and over again with this identical logic. The exception listener/controller will do this all for me.

The problem

My problem with the way it is configured currently is that it isn't flexible. When I want to create some specific behavior for my app exceptions (all, say, inheriting from MyAppException) With the current setup I have 3 options.

  1. Override the view templates of the twig exception controller
  2. Override the twig exception controller method (config.yml)
  3. Roll my own exception listener and fire a subrequest to my own exception controller.

The downsides of all these options are:

  1. I cannot specify an exception design template per bundle. (e.g. error in the FrontendBundle should be able to look different than in the AdminBundle) And I cannot differentiate based on exception class but only on the given exception code.
  2. When I override the twig exception controller I don't have a lot of flexibility, the findtemplate function still handles the search the same so I now need to roll my own templatefinder. I also need to duplicate the functionality the original controller had because otherwise I would be degrading the UX for certain exceptions (e.g. checking for http statuscodes comes to mind).
  3. Currently the best option but also immediately the one that requires the most configuration and time.

These options are all not ideal and especially option 3 is very time consuming, but currently the only way to go.

Solution

I created an exception listener that almost exactly mirrors the one currently used in the httpkernel HttpKernel\Exception\Exceptionlistener. It even uses the same signature to call the controller method.

On top of this I created a class (naming is open for discussion) ExceptionControllers
This class is a tag handler class for the tag myapp_service.exception_controller
This tag has 1 mandatory and 3 optional attributes

  1. exception = the exception you want to handle with this exception controller
  2. method = the controller methodname that gets called (defaults to "showAction")
  3. match = exact or inherited, does the controller only match exceptions with the exact classname or does it allow children of its handled exception. (defaults to "inherited")
  4. bundle-scope = the site/origin of the thrown exception you want to handle e.g. only handle RuntimeException if it originated from the MyAppFrontBundle (defaults to "" which is global)

This ExceptionControllers class is constructor injected into the exception listener which can then 'query' the class for a correct handler.
The priority for handling is narrow, bundle, scope first. Closest match in the inheritance chain is preferred. When a candidate controller set match="exact" the inheritance is n=1 (just itself).
Finally it is up to the controller to issue the correct response and do (additional) logging.

About BC

This solution keeps BC because we could still allow the twig controller to be overridden via the config.yml. What needs to change is the internal handling for the HttpKernel\Exception\Exceptionlistener class which would then get the ExceptionControllers class injected, replacing the "controller" string.

The twig exception controller would then have to be tagged like so (tag name would then be changed of course)
<tag name="myapp_service.exception_controller" exception="Exception" />
Where match="inherited", method="showAction" and bundle-scope="" are implied through defaults.
This will bubble all the unhandled exceptions to this controller if there is no controller closer to the exception in the inheritance chain or a controller in a narrower scope. That is all :D

Examples

<tag name="myapp_service.exception_controller" exception="MyApp\ServiceBundle\Exception\MyAppException" match="exact" />

<tag name="myapp_service.exception_controller" exception="MyApp\ServiceBundle\Exception\MyAppException" match="inheritance" bundle-scope="MyAppFrontBundle" />

Future ideas

Let the ExceptionListener fall back to the next best controller to handle the exception. If the previous controller did not produce a response or threw an error. However we should limit this to trying a maximum amount of controllers to prevent the final response from possibly taking waaay too long.

BC breaking changes

  1. For the best experience the ExceptionListener should refrain from logging and let the controllers handle that. I use an abstract class BaseExceptionController with the logException method from the current ExceptionListener in it to very easily do the logging the ExceptionListener did.
    Possible fix is to only log if the controller is in some way related to the twig controller approach and is therefore using "the old way" (need to be smart about overridden twig controllers).
  2. The dependency ExceptionControllers has on Kernel.
    It could easily be made optional through property injection, or attribute on-invalid="null" and shutting off the bundle-scope functionality if the Kernel is not defined.
@fean
Copy link

fean commented Aug 24, 2014

I think your idea is not bad at all. If I may have a say in this, I'd like to see this in an upcoming commit.

@wouterj
Copy link
Member

wouterj commented Aug 24, 2014

(btw, this should be labelled as DX)

@NinoFloris NinoFloris changed the title Better exception handling (as opposed to the current twig controller) [DX] Better exception handling (as opposed to the current twig controller) Aug 24, 2014
@wouterj
Copy link
Member

wouterj commented Aug 24, 2014

@NinoFloris DX is Developer eXperience, it's the initiative of the Symfony community to improve the experience for first-time users. For more info, see http://symfony.com/blog/making-the-symfony-experience-exceptional

@NinoFloris
Copy link
Author

Should I just create a pull request for this because I don't have the feeling the issues are as actively checked as the pull requests are. Especially by the guys who are to judge the usefulness of this to the framework. (@fabpot , @stof )

P.S. Not meaning to sound like an awful person, I get that you have enough to do ;)

@weaverryan
Copy link
Member

Since it seems like your idea at least has some merit, yes! If you make a PR, then it will be easier for everyone else too see and comment :).

Thanks!

@stof
Copy link
Member

stof commented Aug 26, 2014

There is nothing requiring to use a subrequest to handle the exception inn your listener. You can render the error page directly in the listener.
The reason why the core uses a controller is to provide an extra extension point, by allowing to replace the controller only, without replacing the whole ExceptionListener

@stof
Copy link
Member

stof commented Aug 26, 2014

@NinoFloris issues are checked. But I haven't reviewed all issues this weeked (I have a life outside Symfony) and this one was still in the review queue (my github mailbox). And @fabpot just came back from vacations, so his own queue is probably huge.

And moving the logging from the ExceptionListener to the controller is a BC break. It means that anyone replacing the controller now needs to take care of the logging as well (which is precisely something meant to be avoided in the current setup).

If you don't want the logging for your custom exceptions, use your own listener on the kernel.exception event with a higher priority than the core listener. If you call setResponse in a listener, next listeners will not be called, and so the default logging will not happen.
Using listeners is IMO much more flexible than your proposal. you only allow choosing to support an exception based on its class (with a weird match="exact" preventing to support child classes, which goes against the meaning of inheritance). Registering more listeners allows to use the logic you want in the listener to decide whether you are able to generate a Response for this exception.

@NinoFloris
Copy link
Author

First of I apologize again for coming over like I did. I was not trying to say you didn't care because I see how many issues and PR's there are and I get that you have a life, weekend, and vacation too ;)

About your first post, I like the way it abstracts the process to a controller which fits well into the mental model of a symfony beginner.
"Return a response? A controller does that."
"Return an error/exception response, I would use a controller.".
This 'enhancement' requires the same kind of process but magnifies the amount of extension points.

I really had to dig into the framework to find what handled this twig controller functionality and immediately my own listener felt really crappy because of all the checks and functionality I didn't think of to implement. And then I still had nothing that worked well (this solution is iteration 3)...
Why give the (SF beginner) developer that experience.
All I'm proposing is a little help in this section for people that want to handle exceptions centrally.

I explained match="exact" in this 'weird' way because the match code is just a while loop going up the parent class chain. Starting at the exception class that was thrown.
If it cannot find an exact match it will then make sure all the controllers it tries next have match="inerhited" set.
This says about the controller that it allows, and even expects child exceptions of the type it registered. (E.g. a lone controller registering \Exception with match="inerhited"would get called for every exception type. )

Using listeners IMO is much more flexible. You have a really fair point. I don't object to that.
Because it actually is the most flexible way you can handle this. However this doesn't mean it is the best way, or the nicest way to do it.

  1. For one, kernel priority is something of a pain because you need to know the values of the other listeners to position your new listener correctly.
  2. DX with a listener for exceptions is just not optimal. The use of a listener is probably a new concept while controllers are known and clear. I even just had a grasp on what the concept of the service container is. I could blindly copy paste things to set up a service but create my own services from scratch was not a very clear concept to me yet. Although I had dabbled with Autofac IoC for .Net a while ago the configuration there was mostly in code.
  3. You probably also need to inject quite a couple of services to get a good exception listener of the ground (twig, logger, and doctrine are all good candidates), I had always used the $this->getDoctrine() and $this->render() commands which I had come to know from the controllers. (Oh can I import it with container->get('doctrine') as well?!)
  4. Then there are quite a few things you could easily overlook.
    • Exception flattening was something I had never seen before.
    • Making sure your listener does not itself create a new exception.
    • Implement some static locking magic (only if you subrequest).
    • You need to really know how the kernel works to know answers to "what happens if I throw an exception in this listener"
  5. Finally I don't find it fitting to have to add the exact same listener to all bundles wanting to use that. One kernel event listener somewhere in a bundle or in the framework is fine by me.

I think it is great that with this you can easily have bundle specifc handling (different template) and app wide handling which is seamlessly handled.
Certain bundles that want to handle MyAppDBException specifically? But still provide fallback for the bundles that don't care? As easy as setting up 1+Nspecifc controllers, ofcourse you can also tag a controller Nspecific times. Because the tagging system is great!
Easily handle say RuntimeException (and all that inherits) app wide and at the same time allow for certain inheriting exceptions to be handled with different behavior (globally or bundle scoped) is a big plus I would say.

TL;DR

If you are thinking, just use a switch statement or a listener for every case and set up the priority correctly. Alright. If this is integrated into the framework, the bundles can stay decoupled because they don't have to know which other listeners are registered and how they all relate to each other. They can let the one ExceptionListener do all the magic of figuring out what the best candidate for the job is, and at the same time get a well written listener for free. Without having to learn a whole lot more than the basics of controllers and copy paste service wiring.

Win win.

@stof
Copy link
Member

stof commented Aug 26, 2014

I explained match="exact" in this 'weird' way because the match code is just a while loop going up the parent class chain. Starting at the exception class that was thrown.
If it cannot find an exact match it will then make sure all the controllers it tries next have match="inerhited" set.

this also means it is impossible to match based on interfaces, making it even less flexible than I though

Then there are quite a few things you could easily overlook.

Exception flattening was something I had never seen before.

flattening the exception is mostly meant for the debug error page (to display enough info there). It is not something the prod error page will use. And if your own exception is handled by displayed a detailed stack trace of the exception in the same way, there is no point customizing the rendering

I think it is great that with this you can easily have bundle specifc handling (different template) and app wide handling which is seamlessly handled.
Certain bundles that want to handle MyAppDBException specifically? But still provide fallback for the bundles that don't care? As easy as setting up 1+Nspecifc controllers, ofcourse you can also tag a controller Nspecific times. Because the tagging system is great!

how do you scope exceptions to a bundle ? The stack trace of an exception can go through several bundles (maybe even all bundles in some crazy case), as well as lots of code which is not in any bundle (especially when you follow the practice of having the logic in standalone libraries and having bundles with only the Symfony glue in them)

@NinoFloris
Copy link
Author

About the interfaces, good point, they are not that common but this solution should still work with them. Will add. (adding class_implements() should do the trick).

The scoping. I could change bundle-scope to origin-scope. And check for a path or a bundlename. Or I could implement a separate path-scope variable.

If you really want something with the errors from that library you either target its classes and/or target the bundle where you use that libraries code while making sure to wrap it (and optionally nest the previous exception). I really though of it as a feature to help the dev's that make real bundles. That is why I could permit the choice to take the file location of the last exception in the trace i.e. $ex->getFile(). This should be enough to catch errors that originate from your own bundles. And if it is an error that slipped your code (no try catch) This will still usually be a bundle below that. If however it cannot find the bundle it will try to handle the exception globally. That is expected behavior.

You have poked some holes in a couple of the choices I made and that is a good thing. I sincerely thank you for that because I did not look at it this way yet. I think they are 'easy' fixes and don't degrade the ease of use that a developer would experience.

Full honesty, do you think it could have potential if fully thought through?
I'm really loving Symfony a lot. I wouldn't want to try and add 'crap' to it but I am quite passionate about enhancing DX, there is enough to build and the system is awesome.

@stof
Copy link
Member

stof commented Aug 26, 2014

IMO, anything changing the render of the exception based on the file in which it was thrown instantiated is a hack.

The usage you have for your FrontException can already be achieved with the current feature in a more powerful way

About the interfaces, good point, they are not that common but this solution should still work with them. Will add. (adding class_implements() should do the trick).

This would then required defining in which order we navigate between parent classes and implemented interfaces though, to know which one win (omitting the interfaces was indeed solving this by having a single meaningful order). Controlling which controller wins in this case would probably be even more complex to understand than the priority-based system we have currently (which only requires being before the core listener in simple cases, which is done by default as the core registers with -128 as priority)

@NinoFloris
Copy link
Author

Yes it is absolutely a hack, I was very happy to see however that a Symfony Bundle has the explicit constraint it must end with 'Bundle'. This way I could at least parse the filepath in a standardized way. Which can then be extrapolated back to the bundle again. In the end the getFile gives you the last location where things start to get unhandled. So It is not totally wrong behavior.

But I get it you would prefer to see the first location where things start to go wrong?

The usage you have for your FrontException can already be achieved with the current feature in a more powerful way

I always admitted to that. It's just still not as pretty as typing up a very stupid controller and binding it through the service container.

@stof
Copy link
Member

stof commented Aug 26, 2014

I always admitted to that. It's just still not as pretty as typing up a very stupid controller and binding it through the service container.

Well, writing a simple listener or a simple controller is not that different. And if you want to register the exception controllers through the container, it means defining services for them, which starts making it harder

@NinoFloris
Copy link
Author

You would prefer to see scope on the first location where things start to go wrong?

Would you? My opinion is that the stack trace is useful for debugging purposes but that a final response should always build on the last data it received. And seeing how I implement try catch logic this is very reasonable. I'm quite glad that these library exceptions, through all the layers, have been handled or consolidated into much less options, this narrows my playing field I have to keep track of.

Well, writing a simple listener or a simple controller is not that different. And if you want to register the exception controllers through the container, it means defining services for them, which starts making it harder

I think there is still a meaningful difference due to the amount of easy extra's a controller gets that a listener goes without. This coupled with the known mental image of what a controller is. And if you can take out the thinking about matching, inheritance, bubbling etcetera. This helps a lot.
I think that there lies it strength, helping the developer. Just like symfony/generators helps me to create a bundle, yes I could do it myself but i'd rather do console generate:bundle.
An ~80SLOC file (before interface handling) is not going to do miracles but it can be a nice helper :)
Mind you if it gets in, there will still be 3 ways of doing things (after BC is over).

  1. Roll your own listener
  2. Add an exception controller
  3. Override the twig controller templates
  4. BC Swap out the twig controller in config.yml

The twig controller will still be there as a final catch-all exception controller, which means that the overrides of it's twig templates also still work.

I think 2. is leagues ahead of 4.

About the interfaces, good point, they are not that common but this solution should still work with them. Will add. (adding class_implements() should do the trick).

That is not so much the case because the handler decides which interface it wants to handle. So it would still just be exact > inheritance. Each class in the thrown exception inheritance chain gets scanned for a match, the match was just a class name but it should now also match interfaces. The closer to the original class the better, so if a handler registers MyExceptionInterface and the thrown exception implements it then we are already there. If not move on to the parent class.

I will try to flesh out the idea I have for interface priority vs parent class tomorrow. But luckily the use case for multiple interface matches is very rare:

  • Inherit via some class from Exception.
  • Implement multiple interfaces.
  • Have all or multiple of these interfaces registered to exception controllers.

I would almost say, so rare that for multiple interfaces I would do a 'pick the first match' approach.

@stof
Copy link
Member

stof commented Aug 27, 2014

That is not so much the case because the handler decides which interface it wants to handle. So it would still just be exact > inheritance. Each class in the thrown exception inheritance chain gets scanned for a match, the match was just a class name but it should now also match interfaces. The closer to the original class the better, so if a handler registers MyExceptionInterface and the thrown exception implements it then we are already there. If not move on to the parent class.

except that you cannot know whether the interface was implemented directly or through interface of the parent or of another interface (except by some complex Reflection stuff)

Mind you if it gets in, there will still be 3 ways of doing things

And having several ways to do the same thing is not good for DX (because we need to document all of them, and then it confuses the user).

An ~80SLOC file (before interface handling) is not going to do miracles but it can be a nice helper :)

The issue is that once we had such way to do thing, people will expect to get full flexibility on it, which will make it way more than 80 LOC (with an insane config to support enough cases), and a complex matching of the controller to use.
Thus, a subrequest is expected to always return a Response, so we cannot just simply say "Try this controller, and if it does not give me a response, move on". We have to find the right controller upfront.
On the other hand, for listener, as soon as a listener gives me a response, it stops the dispatching. And listeners can do whatever they want to decide whether they want to build a response, because this is PHP code, not a bunch of tag attributes.

I think there is still a meaningful difference due to the amount of easy extra's a controller gets that a listener goes without.

The controller gets lots of things because you use container-aware controllers with the base controller class. But all your proposal are talking about defining the controller as a service. and for a controller defined as service, making it container-aware is a very bad practice, meaning there is no difference here

On a side note, using listeners will be more efficient, because using subrequests has some overhead.

@NinoFloris
Copy link
Author

Speaking about config :D would be nice if people could do something like:

framework:
  exception_controllers:
    "MyApp\SpecialExController:doAction": {exception: "MyApp\SpecialEx", "match etc": "value etc"  }

This would take away the complexity of knowing anything about services.

I don't think having a couple of options from easy to hardest is damaging to DX, I think it is helpful if you can pick the right option for your needs.

Yes listeners are more efficient but you could of course always call the controller function directly without subrequests and then pass the return value as a response via the listener.
Needing to do it right the first time around is correct but you could always build at least a try catch around it that tries another controller if the previous controller threw an exception.

I have implemented interface matching. The new size is 175 LOC, not too bad. Even if it doesn't come into the framework, which I'm of course expecting by now, it was a really fun exercise.
The process is quite clever (if I say so myself) and I felt like a wizard developing it hahah.

The class only uses class_implements, so no reflection stuff. But it still is complex however.
It fully preserves ordering, finds the explictly and implicitly (interface inheritance) implemented interfaces per class, does interface inheritance realignment onto class inheritance chain and therefore plain works. There is only one case where it cannot get the correct explicit implemented interfaces. This is if a child class is implementing (which is technically a re-implement then) a child interface of a parent interface that was already implemented by a parent class of this child class

Like so

InterfaceA {}
InterfaceB extends InterfaceA
InterfaceC extends InterfaceB

Class ClassA implements InterfaceC { }

Class ClassB extends ClassA implements InterfaceB

In this case the resolver will attribute the interface's A to C to ClassA and won't assign ClassB the exact implement of InterfaceB, there is no way I can detect this with spl functions. It is a weird situation to begin with, the only time I see this happening if one doesn't know a parent already implemented this interface.
PHP will even fatal error if you try this trick on the same class, it is just too lazy to keep track of it when it happens on a child class.

Class ClassA implements InterfaceC, InterfaceA { }

Output: PHP Fatal error:  Class ClassA cannot implement previously implemented interface InterfaceA

Other way around is ok though

Class ClassA implements InterfaceA, InterfaceC { }

About container-aware, you are totally correct, I forgot about that. I use an abstract service definition to inject these candidates through something like parent="base_exception_controller" which I forgot about.

See https://gist.github.com/NinoFloris/92523a34cdf17a084d4d for the interface inheritance resolver resolveInterfaceChain() (which you might be interested in)

Hehehe but alright. It was a lot of fun building this, and I hope I can contribute in some other way in the future.

@javiereguiluz javiereguiluz added the DX DX = Developer eXperience (anything that improves the experience of using Symfony) label Jan 26, 2016
@Gladhon
Copy link

Gladhon commented Feb 15, 2016

+1 for not making a subrequest in exceptionhandling.

@Gladhon
Copy link

Gladhon commented Aug 22, 2016

@NinoFloris why this is closed now ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
DX DX = Developer eXperience (anything that improves the experience of using Symfony) TwigBundle
Projects
None yet
Development

No branches or pull requests

8 participants