Skip to content

[HttpKernel] Add kernel.error event to handle uncaught \Error #34232

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
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpKernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ CHANGELOG
* Marked the `RouterDataCollector::collect()` method as `@final`.
* The `DataCollectorInterface::collect()` and `Profiler::collect()` methods third parameter signature
will be `\Throwable $exception = null` instead of `\Exception $exception = null` in Symfony 5.0.
* Added the `kernel.error` event to handle uncaught `\Error`.

4.3.0
-----
Expand Down
43 changes: 43 additions & 0 deletions src/Symfony/Component/HttpKernel/Event/ErrorEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpKernel\Event;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

final class ErrorEvent extends RequestEvent
{
private $error;
private $allowCustomResponseCode = false;

public function __construct(HttpKernelInterface $kernel, Request $request, int $requestType, \Error $error)
{
parent::__construct($kernel, $request, $requestType);

$this->error = $error;
}

public function getError(): \Error
{
return $this->error;
}

public function allowCustomResponseCode(): void
{
$this->allowCustomResponseCode = true;
}

public function isAllowingCustomResponseCode(): bool
{
return $this->allowCustomResponseCode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,6 @@ public function configure(Event $event = null)
throw $e;
}

if (!$e instanceof \Exception) {
$e = new ErrorException($e);
}

$hasRun = true;
$kernel->terminateWithException($e, $request);
};
Expand Down
37 changes: 25 additions & 12 deletions src/Symfony/Component/HttpKernel/HttpKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\HttpKernel;

use Symfony\Component\ErrorHandler\Exception\ErrorException;
use Symfony\Component\EventDispatcher\LegacyEventDispatcherProxy;
use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -21,6 +22,7 @@
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ErrorEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
Expand Down Expand Up @@ -66,7 +68,7 @@ public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQ

try {
return $this->handleRaw($request, $type);
} catch (\Exception $e) {
} catch (\Throwable $e) {
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 a bc break. If I use the HttpKernel standalone, throwable did not trigger the exception event. Now it does.

Copy link
Member

Choose a reason for hiding this comment

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

We could change the $catch parameter of this method to allow for more fine-grained control over what should be caught.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What about just having 2 channels, exception and error, and we hook the ExceptionListener on both events. Backward compatible and errors can still be handled in 4.4.

if ($e instanceof RequestExceptionInterface) {
$e = new BadRequestHttpException($e->getMessage(), $e);
}
Expand Down Expand Up @@ -198,24 +200,35 @@ private function finishRequest(Request $request, int $type)

/**
* Handles a throwable by trying to convert it to a Response.
*
* @throws \Exception
*/
private function handleThrowable(\Throwable $e, Request $request, int $type): Response
{
$event = new ExceptionEvent($this, $request, $type, $e);
$this->dispatcher->dispatch($event, KernelEvents::EXCEPTION);
if ($e instanceof \Error) {
$event = new ErrorEvent($this, $request, $type, $e);
$this->dispatcher->dispatch($event, KernelEvents::ERROR);

if (!$event->hasResponse()) {
$e = new ErrorException($error = $e);
} else {
$response = $event->getResponse();
}
}

// a listener might have replaced the exception
$e = $event->getException();
if (!isset($response)) {
$event = new ExceptionEvent($this, $request, $type, $e);
$this->dispatcher->dispatch($event, KernelEvents::EXCEPTION);

if (!$event->hasResponse()) {
$this->finishRequest($request, $type);
// a listener might have replaced the exception
$e = $event->getException();

throw $e;
}
if (!$event->hasResponse()) {
$this->finishRequest($request, $type);

$response = $event->getResponse();
throw $error ?? $e;
}

$response = $event->getResponse();
}

// the developer asked for a specific status code
if (!$event->isAllowingCustomResponseCode() && !$response->isClientError() && !$response->isServerError() && !$response->isRedirect()) {
Expand Down
2 changes: 0 additions & 2 deletions src/Symfony/Component/HttpKernel/HttpKernelInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ interface HttpKernelInterface
* @param bool $catch Whether to catch exceptions or not
*
* @return Response A Response instance
*
* @throws \Exception When an Exception occurs during processing
*/
public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true);
}
12 changes: 12 additions & 0 deletions src/Symfony/Component/HttpKernel/KernelEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ final class KernelEvents
*/
const REQUEST = 'kernel.request';

/**
* The ERROR event occurs when an uncaught error appears.
*
* This event allows you to create a response for a thrown error.
*
* If no response is created, the error is converted into an uncaught
* exception (@see KernelEvents::EXCEPTION).
*
* @Event("Symfony\Component\HttpKernel\Event\ErrorEvent")
*/
const ERROR = 'kernel.error';

/**
* The EXCEPTION event occurs when an uncaught exception appears.
*
Expand Down
55 changes: 55 additions & 0 deletions src/Symfony/Component/HttpKernel/Tests/HttpKernelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\HttpKernel\Tests;

use PHPUnit\Framework\TestCase;
use Symfony\Component\ErrorHandler\Exception\ErrorException;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
Expand All @@ -20,6 +21,8 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Event\ErrorEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ControllerDoesNotReturnResponseException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
Expand Down Expand Up @@ -333,6 +336,58 @@ public function testInconsistentClientIpsOnMasterRequests()
Request::setTrustedProxies([], -1);
}

public function testHandleErrorDirectRethrow()
{
$this->expectException(\DivisionByZeroError::class);
$kernel = $this->getHttpKernel(new EventDispatcher(), static function (): void { throw new \DivisionByZeroError(); });

$kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, false);
}

public function testHandleErrorWithNoListeners()
{
$this->expectException(\DivisionByZeroError::class);
$kernel = $this->getHttpKernel(new EventDispatcher(), static function (): void { throw new \DivisionByZeroError(); });

$kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, true);
}

public function testHandleErrorWithAnErrorListenerThatSetsAResponse()
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener(KernelEvents::ERROR, static function (ErrorEvent $event): void {
$event->setResponse(new Response('from error'));
});
$dispatcher->addListener(KernelEvents::EXCEPTION, function (ExceptionEvent $event): void {
$this->fail('This listener should not be called');
});

$kernel = $this->getHttpKernel($dispatcher, static function (): void { throw new \DivisionByZeroError(); });
$response = $kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, true);

$this->assertSame('from error', $response->getContent());
}

public function testHandleErrorWithAnErrorListenerThatDontSetAResponse()
{
$dispatcher = new EventDispatcher();
$errorListenerWasCalled = false;
$dispatcher->addListener(KernelEvents::ERROR, static function () use (&$errorListenerWasCalled): void {
$errorListenerWasCalled = true;
});
$dispatcher->addListener(KernelEvents::EXCEPTION, function (ExceptionEvent $event): void {
$this->assertInstanceOf(ErrorException::class, $event->getException());

$event->setResponse(new Response('from exception'));
});

$kernel = $this->getHttpKernel($dispatcher, static function (): void { throw new \DivisionByZeroError(); });
$response = $kernel->handle(new Request(), HttpKernelInterface::MASTER_REQUEST, true);

$this->assertTrue($errorListenerWasCalled);
$this->assertSame('from exception', $response->getContent());
}

private function getHttpKernel(EventDispatcherInterface $eventDispatcher, $controller = null, RequestStack $requestStack = null, array $arguments = [])
{
if (null === $controller) {
Expand Down