feat: implement exception handling system with error context and policies

This commit is contained in:
2025-11-01 15:46:43 +01:00
parent f3440dff0d
commit a441da37f6
35 changed files with 920 additions and 88 deletions

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use ErrorException;
final readonly class ErrorContext
{
public function __construct(
public int $severity,
public string $message,
public ?string $file = null,
public ?int $line = null,
public bool $isSuppressed = false,
) {}
public static function create(
int $severity,
string $message,
?string $file = null,
?int $line = null,
bool $isSuppressed = false,
): self {
return new self($severity, $message, $file, $line, $isSuppressed);
}
public function isDeprecation(): bool
{
return $this->severity === E_DEPRECATED || $this->severity === E_USER_DEPRECATED;
}
public function isFatal(): bool
{
return in_array($this->severity, [E_ERROR, E_RECOVERABLE_ERROR, E_USER_ERROR], true);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\ExceptionHandling;
enum ErrorDecision
{
/** Fehler selbst behandelt (kein PHP-Standard mehr) */
case HANDLED;
/** An PHP-Standard weitergeben */
case DEFER;
/** Eskalieren als Exception */
case THROW;
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Strategy\StrictErrorPolicy;
use ErrorException;
final readonly class ErrorHandler
{
public function __construct(
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
)
{}
/**
* @throws ErrorException
*/
public function handle(
int $severity,
string $message,
?string $file = null,
?int $line = null,
): bool {
$context = ErrorContext::create(
severity : $severity,
message : $message,
file : $file,
line : $line,
isSuppressed: $this->isSuppressed($severity)
);
$decision = $this->strategy->handle($context);
return match($decision) {
ErrorDecision::HANDLED => true,
ErrorDecision::DEFER => false,
ErrorDecision::THROW => throw new ErrorException($message, 0, $severity, $file, $line),
};
}
private function isSuppressed($severity): bool
{
return !(error_reporting() & $severity);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Framework\ExceptionHandling;
use ErrorException;
interface ErrorHandlerStrategy
{
public function handle(ErrorContext $context): ErrorDecision;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Reporter\LogReporter;
use Throwable;
final readonly class ErrorKernel
{
public function __construct(
private ErrorRendererFactory $rendererFactory = new ErrorRendererFactory,
) {}
public function handle(Throwable $e, array $context = []): mixed
{
$log = new LogReporter();
$log->report($e->getMessage());
$this->rendererFactory->getRenderer()->render();
return null;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling;
interface ErrorRenderer
{
public function render(): void;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Renderer\HtmlErrorRenderer;
final class ErrorRendererFactory
{
public function getRenderer():ErrorRenderer
{
return new HtmlErrorRenderer();
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\DI\Initializer;
use Fiber;
final class ErrorScope
{
private array $stack = [];
#[Initializer]
public static function initialize(): ErrorScope
{
return new self;
}
public function enter(ErrorScopeContext $context): int
{
$id = $this->fiberId();
$this->stack[$id] ??= [];
$this->stack[$id][] = $context;
return count($this->stack[$id]);
}
public function current(): ?ErrorScopeContext
{
$id = $this->fiberId();
$stack = $this->stack[$id] ?? [];
return end($stack) ?? null;
}
public function leave(int $token): void
{
$id = $this->fiberId();
if(!isset($this->stack[$id])) {
return;
}
while(!empty($this->stack[$id]) && count($this->stack[$id]) >= $token) {
array_pop($this->stack[$id]);
}
if(empty($this->stack[$id])) {
unset($this->stack[$id]);
}
}
private function fiberId(): int
{
$fiber = Fiber::getCurrent();
return $fiber ? spl_object_id($fiber) : 0;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
final class ErrorScopeContext
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Framework\ExceptionHandling;
use Throwable;
interface ExceptionHandler
{
public function handle(Throwable $throwable): void;
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Config\EnvironmentType;
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
final readonly class ExceptionHandlerManager
{
public function __construct()
{
$resolver = new ErrorPolicyResolver();
$this->registerErrorHandler(new ErrorHandler($resolver->resolve(EnvironmentType::DEV)));
$this->registerExceptionHandler(new GlobalExceptionHandler());
$this->registerShutdownHandler(new ShutdownHandler());
}
public function registerExceptionHandler(ExceptionHandler $handler): void
{
set_exception_handler($handler->handle(...));
}
private function registerErrorHandler(ErrorHandler $handler):void
{
set_error_handler($handler->handle(...), E_ALL);
}
public function registerShutdownHandler(ShutdownHandler $handler): void
{
register_shutdown_function($handler->handle(...));
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
final readonly class GlobalExceptionHandler implements ExceptionHandler
{
public function handle(\Throwable $throwable): void
{
$kernel = new ErrorKernel();
$kernel->handle($throwable);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Renderer;
use App\Framework\ExceptionHandling\ErrorRenderer;
final class HtmlErrorRenderer implements ErrorRenderer
{
public function render(): void
{
echo '<html lang="en"><body><h1>500 Internal Server Error</h1></body></html>';
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;
final class LogReporter implements Reporter
{
public function report(string $message): void
{
echo ("[log] " . $message);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\ExceptionHandling\Reporter;
interface Reporter
{
public function report(string $message): void;
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ExceptionHandling\Strategy\StrictErrorPolicy;
use Error;
final readonly class ShutdownHandler
{
private const array FATAL_TYPES = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
public function handle(): void
{
$last = error_get_last();
if (!$last || !$this->isFatalError($last['type'])) {
return;
}
$this->cleanOutputBuffer();
$file = (string)($last['file'] ?? 'unknown');
$line = (int)($last['line'] ?? 0);
$error = new Error($last['message'] ?? 'Fatal error',0);
try {
$ehm = new ErrorKernel();
$ehm->handle($error, ['file' => $file, 'line' => $line]);
} catch (\Throwable) {
}
exit(255);
}
private function cleanOutputBuffer(): void
{
try {
while (ob_get_level() > 0) {
@ob_end_clean();
}
} catch (\Throwable) {
// ignore
}
}
private function isFatalError(?int $type = null): bool
{
return in_array($type ?? 0, self::FATAL_TYPES, true);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\Config\EnvironmentType;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
final readonly class ErrorPolicyResolver
{
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
{
return match(true) {
$environmentType->isProduction() => new StrictErrorPolicy(),
$environmentType->isDevelopment() => new StrictErrorPolicy(),
default => new StrictErrorPolicy(),
};
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\ExceptionHandling\ErrorContext;
use App\Framework\ExceptionHandling\ErrorDecision;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use ErrorException;
final readonly class LenientPolicy implements ErrorHandlerStrategy
{
public function __construct(private Logger $logger)
{
}
public function handle(ErrorContext $context): ErrorDecision
{
if($context->isDeprecation()) {
$this->logger->notice("[Deprecation] {$context->message}",
LogContext::withData(
[
'file' => $context->file,
'line' => $context->line]
));
return ErrorDecision::HANDLED;
}
if($context->isFatal()) {
throw new ErrorException(
$context->message,
0,
$context->severity,
$context->file,
$context->line
);
}
return ErrorDecision::DEFER;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\ExceptionHandling\ErrorContext;
use App\Framework\ExceptionHandling\ErrorDecision;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
final class SilentErrorPolicy implements ErrorHandlerStrategy
{
public function handle(ErrorContext $context): ErrorDecision
{
return ErrorDecision::DEFER;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\ExceptionHandling\ErrorContext;
use App\Framework\ExceptionHandling\ErrorDecision;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
use ErrorException;
final readonly class StrictErrorPolicy implements ErrorHandlerStrategy
{
/**
* @throws ErrorException
*/
public function handle(ErrorContext $context): ErrorDecision
{
throw new ErrorException($context->message, 0, $context->severity, $context->file, $context->line);
}
}