Skip to content

Commit 869eff0

Browse files
authored
Merge pull request #126 from phenixphp/feature/server-error-handling
Implement global error handling and reporting with JSON support
2 parents 56e4690 + 62b5449 commit 869eff0

8 files changed

Lines changed: 482 additions & 25 deletions

File tree

src/App.php

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
namespace Phenix;
66

77
use Amp\Cluster\Cluster;
8-
use Amp\Http\Server\DefaultErrorHandler;
98
use Amp\Http\Server\Driver\ConnectionLimitingClientFactory;
109
use Amp\Http\Server\Driver\ConnectionLimitingServerSocketFactory;
1110
use Amp\Http\Server\Driver\SocketClientFactory;
11+
use Amp\Http\Server\ExceptionHandler as ExceptionHandlerContract;
1212
use Amp\Http\Server\Middleware;
1313
use Amp\Http\Server\Middleware\CompressionMiddleware;
14+
use Amp\Http\Server\Middleware\ExceptionHandlerMiddleware;
1415
use Amp\Http\Server\Middleware\ForwardedHeaderType;
1516
use Amp\Http\Server\RequestHandler;
1617
use Amp\Http\Server\Router;
@@ -33,6 +34,8 @@
3334
use Phenix\Facades\Config;
3435
use Phenix\Facades\Route;
3536
use Phenix\Http\Constants\Protocol;
37+
use Phenix\Http\ErrorHandler as AppErrorHandler;
38+
use Phenix\Http\ExceptionHandler as AppExceptionHandler;
3639
use Phenix\Logging\LoggerFactory;
3740
use Phenix\Runtime\Log;
3841
use Phenix\Scheduling\TimerRegistry;
@@ -60,7 +63,9 @@ class App implements AppContract, Makeable
6063

6164
protected bool $signalTrapping = true;
6265

63-
protected DefaultErrorHandler $errorHandler;
66+
protected AppErrorHandler $errorHandler;
67+
68+
protected ExceptionHandlerContract $exceptionHandler;
6469

6570
protected Protocol $protocol = Protocol::HTTP;
6671

@@ -75,7 +80,8 @@ public function __construct(string $path)
7580
self::$path = $path;
7681
self::$container = new Container();
7782

78-
$this->errorHandler = new DefaultErrorHandler();
83+
$this->errorHandler = new AppErrorHandler();
84+
$this->exceptionHandler = new AppExceptionHandler($this->errorHandler);
7985
}
8086

8187
public function setup(): void
@@ -242,18 +248,24 @@ protected function createServer(): SocketHttpServer
242248
}
243249

244250
return SocketHttpServer::createForBehindProxy(
245-
$this->logger,
246-
ForwardedHeaderType::XForwardedFor,
247-
$trustedProxies
251+
logger: $this->logger,
252+
headerType: ForwardedHeaderType::XForwardedFor,
253+
trustedProxies: $trustedProxies,
254+
exceptionHandler: $this->exceptionHandler
248255
);
249256
}
250257

251-
return SocketHttpServer::createForDirectAccess($this->logger);
258+
return SocketHttpServer::createForDirectAccess(
259+
logger: $this->logger,
260+
exceptionHandler: $this->exceptionHandler
261+
);
252262
}
253263

254264
protected function createClusterServer(): SocketHttpServer
255265
{
256-
$middleware = [];
266+
$middleware = [
267+
new ExceptionHandlerMiddleware($this->exceptionHandler),
268+
];
257269
$allowedMethods = Middleware\AllowedMethodsMiddleware::DEFAULT_ALLOWED_METHODS;
258270

259271
if (extension_loaded('zlib')) {
@@ -271,34 +283,34 @@ protected function createClusterServer(): SocketHttpServer
271283
$middleware[] = new Middleware\ForwardedMiddleware(ForwardedHeaderType::XForwardedFor, $trustedProxies);
272284

273285
return new SocketHttpServer(
274-
$this->logger,
275-
Cluster::getServerSocketFactory(),
276-
new SocketClientFactory($this->logger),
277-
$middleware,
278-
$allowedMethods,
286+
logger: $this->logger,
287+
serverSocketFactory: Cluster::getServerSocketFactory(),
288+
clientFactory: new SocketClientFactory(logger: $this->logger),
289+
middleware: $middleware,
290+
allowedMethods: $allowedMethods,
279291
);
280292
}
281293

282294
$connectionLimit = 1000;
283295
$connectionLimitPerIp = 10;
284296

285297
$serverSocketFactory = new ConnectionLimitingServerSocketFactory(
286-
new LocalSemaphore($connectionLimit),
287-
Cluster::getServerSocketFactory(),
298+
semaphore: new LocalSemaphore($connectionLimit),
299+
socketServerFactory: Cluster::getServerSocketFactory(),
288300
);
289301

290302
$clientFactory = new ConnectionLimitingClientFactory(
291-
new SocketClientFactory($this->logger),
292-
$this->logger,
293-
$connectionLimitPerIp,
303+
clientFactory: new SocketClientFactory(logger: $this->logger),
304+
logger: $this->logger,
305+
connectionsPerIpLimit: $connectionLimitPerIp,
294306
);
295307

296308
return new SocketHttpServer(
297-
$this->logger,
298-
$serverSocketFactory,
299-
$clientFactory,
300-
$middleware,
301-
$allowedMethods,
309+
logger: $this->logger,
310+
serverSocketFactory: $serverSocketFactory,
311+
clientFactory: $clientFactory,
312+
middleware: $middleware,
313+
allowedMethods: $allowedMethods,
302314
);
303315
}
304316

src/AppProxy.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
namespace Phenix;
66

77
use Phenix\Contracts\App as AppContract;
8+
use Phenix\Facades\Config;
9+
use Phenix\Runtime\ErrorHandling\GlobalErrorHandler;
10+
use Throwable;
811

912
class AppProxy implements AppContract
1013
{
@@ -18,16 +21,28 @@ public function __construct(
1821

1922
public function run(): void
2023
{
24+
$this->configureErrorReporting();
25+
26+
GlobalErrorHandler::register();
27+
2128
if ($this->testingMode) {
2229
$this->app->disableSignalTrapping();
2330
}
2431

25-
$this->app->run();
32+
try {
33+
$this->app->run();
34+
} catch (Throwable $exception) {
35+
GlobalErrorHandler::restore();
36+
37+
throw $exception;
38+
}
2639
}
2740

2841
public function stop(): void
2942
{
3043
$this->app->stop();
44+
45+
GlobalErrorHandler::restore();
3146
}
3247

3348
public function swap(string $key, object $concrete): void
@@ -46,4 +61,17 @@ public function enableTestingMode(): self
4661

4762
return $this;
4863
}
64+
65+
private function configureErrorReporting(): void
66+
{
67+
$debug = Config::get('app.debug') === true;
68+
69+
error_reporting(E_ALL);
70+
ini_set('display_errors', $debug ? '1' : '0');
71+
ini_set('display_startup_errors', $debug ? '1' : '0');
72+
73+
if ($debug) {
74+
ini_set('log_errors', '1');
75+
}
76+
}
4977
}

src/Http/ErrorHandler.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phenix\Http;
6+
7+
use Amp\Http\HttpStatus;
8+
use Amp\Http\Server\ErrorHandler as ErrorHandlerContract;
9+
use Amp\Http\Server\Request;
10+
use Amp\Http\Server\Response;
11+
use Phenix\Facades\Config;
12+
13+
class ErrorHandler implements ErrorHandlerContract
14+
{
15+
public function handleError(int $status, ?string $reason = null, ?Request $request = null): Response
16+
{
17+
$message = $reason ?? HttpStatus::getReason($status);
18+
$payload = [
19+
'success' => false,
20+
'error' => $message,
21+
'status' => $status,
22+
];
23+
24+
if ($this->shouldExposeDebugDetails()) {
25+
$payload['debug'] = [
26+
'reason' => $message,
27+
'path' => $request?->getUri()->getPath(),
28+
];
29+
}
30+
31+
return $this->json($payload, $status, $reason);
32+
}
33+
34+
/**
35+
* @param array<string, mixed> $payload
36+
*/
37+
public function json(array $payload, int $status, ?string $reason = null): Response
38+
{
39+
$response = new Response(
40+
headers: [
41+
'content-type' => 'application/json',
42+
],
43+
body: json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
44+
);
45+
46+
$response->setStatus($status, $reason);
47+
48+
return $response;
49+
}
50+
51+
public function shouldExposeDebugDetails(): bool
52+
{
53+
return Config::get('app.debug') === true;
54+
}
55+
}

src/Http/ExceptionHandler.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phenix\Http;
6+
7+
use Amp\Http\HttpStatus;
8+
use Amp\Http\Server\ExceptionHandler as ExceptionHandlerContract;
9+
use Amp\Http\Server\Request;
10+
use Amp\Http\Server\Response;
11+
use Throwable;
12+
13+
class ExceptionHandler implements ExceptionHandlerContract
14+
{
15+
public function __construct(
16+
private readonly ErrorHandler $errorHandler
17+
) {
18+
}
19+
20+
public function handleException(Request $request, Throwable $exception): Response
21+
{
22+
report($exception, [
23+
'method' => $request->getMethod(),
24+
'uri' => (string) $request->getUri(),
25+
'client' => $request->getClient()->getRemoteAddress()->toString(),
26+
]);
27+
28+
if (! $this->errorHandler->shouldExposeDebugDetails()) {
29+
return $this->errorHandler->handleError(status: HttpStatus::INTERNAL_SERVER_ERROR, request: $request);
30+
}
31+
32+
return $this->errorHandler->json(payload: [
33+
'success' => false,
34+
'error' => $exception->getMessage(),
35+
'status' => HttpStatus::INTERNAL_SERVER_ERROR,
36+
'debug' => [
37+
'exception' => $exception::class,
38+
'file' => $exception->getFile(),
39+
'line' => $exception->getLine(),
40+
'path' => $request->getUri()->getPath(),
41+
],
42+
], status: HttpStatus::INTERNAL_SERVER_ERROR);
43+
}
44+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phenix\Runtime\ErrorHandling;
6+
7+
use ErrorException;
8+
use Throwable;
9+
10+
use function in_array;
11+
12+
class GlobalErrorHandler
13+
{
14+
private const FATAL_ERRORS = [
15+
E_ERROR,
16+
E_PARSE,
17+
E_CORE_ERROR,
18+
E_COMPILE_ERROR,
19+
];
20+
21+
private static bool $registered = false;
22+
23+
private static bool $active = false;
24+
25+
/**
26+
* @var callable|null
27+
*/
28+
private static $previousExceptionHandler = null;
29+
30+
public static function register(): void
31+
{
32+
if (self::$active) {
33+
return;
34+
}
35+
36+
self::$active = true;
37+
set_error_handler(self::handleError(...));
38+
self::$previousExceptionHandler = set_exception_handler(self::handleException(...));
39+
40+
if (! self::$registered) {
41+
register_shutdown_function(self::handleShutdown(...));
42+
self::$registered = true;
43+
}
44+
}
45+
46+
public static function restore(): void
47+
{
48+
if (! self::$active) {
49+
return;
50+
}
51+
52+
restore_error_handler();
53+
restore_exception_handler();
54+
55+
self::$previousExceptionHandler = null;
56+
self::$active = false;
57+
}
58+
59+
public static function handleError(int $severity, string $message, string $file, int $line): bool
60+
{
61+
if ((error_reporting() & $severity) === 0) {
62+
return false;
63+
}
64+
65+
$exception = new ErrorException($message, 0, $severity, $file, $line);
66+
67+
report($exception, [
68+
'severity' => $severity,
69+
'source' => 'php-error',
70+
]);
71+
72+
throw $exception;
73+
}
74+
75+
public static function handleException(Throwable $exception): void
76+
{
77+
report($exception, [
78+
'source' => 'uncaught-exception',
79+
]);
80+
81+
if (self::$previousExceptionHandler !== null) {
82+
(self::$previousExceptionHandler)($exception);
83+
}
84+
}
85+
86+
public static function handleShutdown(): void
87+
{
88+
self::handleShutdownError(error_get_last());
89+
}
90+
91+
/**
92+
* @param array{type: int, message: string, file: string, line: int}|null $error
93+
*/
94+
public static function handleShutdownError(array|null $error): void
95+
{
96+
if (! self::$active) {
97+
return;
98+
}
99+
100+
if ($error === null || ! in_array($error['type'], self::FATAL_ERRORS, true)) {
101+
return;
102+
}
103+
104+
report(new ErrorException(
105+
$error['message'],
106+
0,
107+
$error['type'],
108+
$error['file'],
109+
$error['line']
110+
), [
111+
'severity' => $error['type'],
112+
'source' => 'fatal-error',
113+
]);
114+
}
115+
}

0 commit comments

Comments
 (0)