Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Async;
|
||||
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when both async operation and fallback fail
|
||||
*/
|
||||
final class AsyncBoundaryFailedException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly string $boundaryName,
|
||||
private readonly Throwable $originalException,
|
||||
private readonly Throwable $fallbackException,
|
||||
int $code = 0,
|
||||
?Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the boundary name where failure occurred
|
||||
*/
|
||||
public function getBoundaryName(): string
|
||||
{
|
||||
return $this->boundaryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original operation exception
|
||||
*/
|
||||
public function getOriginalException(): Throwable
|
||||
{
|
||||
return $this->originalException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fallback exception
|
||||
*/
|
||||
public function getFallbackException(): Throwable
|
||||
{
|
||||
return $this->fallbackException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed error information
|
||||
*/
|
||||
public function getErrorDetails(): array
|
||||
{
|
||||
return [
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'original_error' => [
|
||||
'class' => $this->originalException::class,
|
||||
'message' => $this->originalException->getMessage(),
|
||||
'file' => $this->originalException->getFile(),
|
||||
'line' => $this->originalException->getLine(),
|
||||
],
|
||||
'fallback_error' => [
|
||||
'class' => $this->fallbackException::class,
|
||||
'message' => $this->fallbackException->getMessage(),
|
||||
'file' => $this->fallbackException->getFile(),
|
||||
'line' => $this->fallbackException->getLine(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
171
src/Framework/ErrorBoundaries/Async/AsyncBulkResult.php
Normal file
171
src/Framework/ErrorBoundaries/Async/AsyncBulkResult.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Async;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Result object for async bulk operations
|
||||
*/
|
||||
final readonly class AsyncBulkResult
|
||||
{
|
||||
public function __construct(
|
||||
public array $results,
|
||||
public array $errors,
|
||||
public int $totalOperations,
|
||||
public int $successfulOperations,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success rate as percentage
|
||||
*/
|
||||
public function getSuccessRate(): Percentage
|
||||
{
|
||||
if ($this->totalOperations === 0) {
|
||||
return Percentage::fromFloat(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromFloat($this->successfulOperations / $this->totalOperations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error rate as percentage
|
||||
*/
|
||||
public function getErrorRate(): Percentage
|
||||
{
|
||||
if ($this->totalOperations === 0) {
|
||||
return Percentage::fromFloat(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromFloat($this->getErrorCount() / $this->totalOperations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of errors
|
||||
*/
|
||||
public function getErrorCount(): int
|
||||
{
|
||||
return count($this->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any operations succeeded
|
||||
*/
|
||||
public function hasSuccesses(): bool
|
||||
{
|
||||
return $this->successfulOperations > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any operations failed
|
||||
*/
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return count($this->errors) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all operations succeeded
|
||||
*/
|
||||
public function isCompleteSuccess(): bool
|
||||
{
|
||||
return $this->successfulOperations === $this->totalOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all operations failed
|
||||
*/
|
||||
public function isCompleteFailure(): bool
|
||||
{
|
||||
return count($this->errors) === $this->totalOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all successful results
|
||||
*/
|
||||
public function getSuccessfulResults(): array
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all errors
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error by key
|
||||
*/
|
||||
public function getError(string $key): ?Throwable
|
||||
{
|
||||
return $this->errors[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get result by key
|
||||
*/
|
||||
public function getResult(string $key): mixed
|
||||
{
|
||||
return $this->results[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key has error
|
||||
*/
|
||||
public function hasError(string $key): bool
|
||||
{
|
||||
return isset($this->errors[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key has result
|
||||
*/
|
||||
public function hasResult(string $key): bool
|
||||
{
|
||||
return isset($this->results[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'total_operations' => $this->totalOperations,
|
||||
'successful_operations' => $this->successfulOperations,
|
||||
'failed_operations' => $this->getErrorCount(),
|
||||
'success_rate' => $this->getSuccessRate()->toFloat(),
|
||||
'error_rate' => $this->getErrorRate()->toFloat(),
|
||||
'is_complete_success' => $this->isCompleteSuccess(),
|
||||
'is_complete_failure' => $this->isCompleteFailure(),
|
||||
'has_partial_success' => $this->hasSuccesses() && $this->hasErrors(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'results' => $this->results,
|
||||
'errors' => array_map(
|
||||
fn (Throwable $e) => [
|
||||
'class' => $e::class,
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
],
|
||||
$this->errors
|
||||
),
|
||||
'summary' => $this->getSummary(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Async;
|
||||
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Exception thrown when async circuit breaker is open
|
||||
*/
|
||||
final class AsyncCircuitBreakerOpenException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
382
src/Framework/ErrorBoundaries/Async/AsyncErrorBoundary.php
Normal file
382
src/Framework/ErrorBoundaries/Async/AsyncErrorBoundary.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Async;
|
||||
|
||||
use App\Framework\Async\AsyncPromise;
|
||||
use App\Framework\Async\FiberManager;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\ErrorBoundaries\BoundaryConfig;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryExecutionFailed;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryExecutionSucceeded;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryFallbackExecuted;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
|
||||
use App\Framework\Logging\Logger;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Async Error Boundary using framework's async components
|
||||
*/
|
||||
final readonly class AsyncErrorBoundary
|
||||
{
|
||||
public function __construct(
|
||||
private string $boundaryName,
|
||||
private BoundaryConfig $config,
|
||||
private FiberManager $fiberManager,
|
||||
private Clock $clock,
|
||||
private Timer $timer,
|
||||
private ?Logger $logger = null,
|
||||
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
|
||||
private ?BoundaryEventPublisher $eventPublisher = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute async operation with error boundary
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param callable(): T|null $fallback
|
||||
* @return AsyncPromise<T>
|
||||
*/
|
||||
public function executeAsync(callable $operation, ?callable $fallback = null): AsyncPromise
|
||||
{
|
||||
return AsyncPromise::create(function () use ($operation, $fallback) {
|
||||
$startTime = $this->clock->time();
|
||||
|
||||
try {
|
||||
$result = $this->executeWithRetryAsync($operation);
|
||||
$executionTime = $startTime->age($this->clock);
|
||||
|
||||
// Publish success event
|
||||
$this->publishEvent(new BoundaryExecutionSucceeded(
|
||||
boundaryName: $this->boundaryName,
|
||||
executionTime: $executionTime,
|
||||
message: 'Async operation completed successfully',
|
||||
));
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$executionTime = $startTime->age($this->clock);
|
||||
|
||||
// Publish failure event
|
||||
$this->publishEvent(new BoundaryExecutionFailed(
|
||||
boundaryName: $this->boundaryName,
|
||||
exception: $e,
|
||||
executionTime: $executionTime,
|
||||
willRetry: false,
|
||||
message: 'Async operation failed',
|
||||
));
|
||||
|
||||
return $this->handleFailureAsync($e, $fallback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple async operations concurrently with individual boundaries
|
||||
*
|
||||
* @param array<string, callable> $operations
|
||||
* @param array<string, callable>|null $fallbacks
|
||||
* @return AsyncPromise<array>
|
||||
*/
|
||||
public function executeConcurrent(array $operations, ?array $fallbacks = null): AsyncPromise
|
||||
{
|
||||
$promises = [];
|
||||
|
||||
foreach ($operations as $name => $operation) {
|
||||
$fallback = $fallbacks[$name] ?? null;
|
||||
$promises[$name] = $this->executeAsync($operation, $fallback);
|
||||
}
|
||||
|
||||
return AsyncPromise::all($promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute with timeout using framework components
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param Duration $timeout
|
||||
* @param callable(): T|null $fallback
|
||||
* @return AsyncPromise<T>
|
||||
*/
|
||||
public function executeWithTimeout(
|
||||
callable $operation,
|
||||
Duration $timeout,
|
||||
?callable $fallback = null
|
||||
): AsyncPromise {
|
||||
return AsyncPromise::create(function () use ($operation, $timeout, $fallback) {
|
||||
try {
|
||||
return $this->fiberManager->withTimeoutDuration($operation, $timeout);
|
||||
} catch (Throwable $e) {
|
||||
$this->publishEvent(new BoundaryTimeoutOccurred(
|
||||
boundaryName: $this->boundaryName,
|
||||
timeoutThreshold: $timeout,
|
||||
actualExecutionTime: $timeout, // We don't know the exact time here
|
||||
fallbackExecuted: $fallback !== null,
|
||||
message: "Async operation timed out after {$timeout->toHumanReadable()}",
|
||||
));
|
||||
|
||||
if ($fallback !== null) {
|
||||
return $fallback();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute with circuit breaker support
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param callable(): T|null $fallback
|
||||
* @return AsyncPromise<T>
|
||||
*/
|
||||
public function executeWithCircuitBreaker(callable $operation, ?callable $fallback = null): AsyncPromise
|
||||
{
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
if ($this->circuitBreakerManager->isCircuitOpen($this->boundaryName, $this->config)) {
|
||||
$this->log('info', 'Circuit breaker is open, using fallback');
|
||||
|
||||
if ($fallback !== null) {
|
||||
return AsyncPromise::resolve($fallback());
|
||||
}
|
||||
|
||||
return AsyncPromise::reject(new AsyncCircuitBreakerOpenException(
|
||||
"Circuit breaker is open for boundary '{$this->boundaryName}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return AsyncPromise::create(function () use ($operation, $fallback) {
|
||||
try {
|
||||
$result = $operation();
|
||||
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
$this->circuitBreakerManager->recordSuccess($this->boundaryName, $this->config);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
$this->circuitBreakerManager->recordFailure($this->boundaryName, $this->config);
|
||||
}
|
||||
|
||||
return $this->handleFailureAsync($e, $fallback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute batch operations with individual error boundaries
|
||||
*
|
||||
* @template T
|
||||
* @param array<T> $items
|
||||
* @param callable(T): mixed $processor
|
||||
* @param int $maxConcurrency
|
||||
* @return AsyncPromise<AsyncBulkResult>
|
||||
*/
|
||||
public function executeBatch(
|
||||
array $items,
|
||||
callable $processor,
|
||||
int $maxConcurrency = 10
|
||||
): AsyncPromise {
|
||||
return AsyncPromise::create(function () use ($items, $processor, $maxConcurrency) {
|
||||
$operations = [];
|
||||
|
||||
foreach ($items as $key => $item) {
|
||||
$operations[$key] = fn () => $processor($item);
|
||||
}
|
||||
|
||||
$results = $this->fiberManager->throttled($operations, $maxConcurrency);
|
||||
|
||||
$successful = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
if ($result instanceof Throwable) {
|
||||
$errors[$key] = $result;
|
||||
} else {
|
||||
$successful[$key] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return new AsyncBulkResult(
|
||||
results: $successful,
|
||||
errors: $errors,
|
||||
totalOperations: count($items),
|
||||
successfulOperations: count($successful),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with retry using framework components
|
||||
*/
|
||||
private function executeWithRetryAsync(callable $operation): mixed
|
||||
{
|
||||
$lastException = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $this->config->maxRetries; $attempt++) {
|
||||
try {
|
||||
return $operation();
|
||||
} catch (Throwable $e) {
|
||||
$lastException = $e;
|
||||
|
||||
// Don't retry certain types of errors
|
||||
if (! $this->shouldRetry($e)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($attempt < $this->config->maxRetries) {
|
||||
$delay = $this->calculateRetryDelay($attempt);
|
||||
$this->log('info', "Async attempt {$attempt} failed, retrying in {$delay}ms", [
|
||||
'exception' => $e->getMessage(),
|
||||
'attempt' => $attempt,
|
||||
'delay' => $delay,
|
||||
]);
|
||||
|
||||
// Use framework's timer for async sleep
|
||||
$this->timer->sleep(Duration::fromMilliseconds($delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failure with async fallback
|
||||
*/
|
||||
private function handleFailureAsync(Throwable $exception, ?callable $fallback): mixed
|
||||
{
|
||||
$this->logFailure($exception, 'Async operation failed');
|
||||
|
||||
if ($fallback === null) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $fallback();
|
||||
|
||||
// Publish fallback executed event
|
||||
$this->publishEvent(new BoundaryFallbackExecuted(
|
||||
boundaryName: $this->boundaryName,
|
||||
originalException: $exception,
|
||||
fallbackReason: 'Async operation failed: ' . $exception->getMessage(),
|
||||
message: 'Async fallback executed successfully',
|
||||
));
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (Throwable $fallbackException) {
|
||||
$this->log('error', 'Async fallback also failed', [
|
||||
'original_exception' => $exception->getMessage(),
|
||||
'fallback_exception' => $fallbackException->getMessage(),
|
||||
]);
|
||||
|
||||
throw new AsyncBoundaryFailedException(
|
||||
"Both async operation and fallback failed in boundary '{$this->boundaryName}'",
|
||||
$this->boundaryName,
|
||||
$exception,
|
||||
$fallbackException
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exception should trigger retry
|
||||
*/
|
||||
private function shouldRetry(Throwable $e): bool
|
||||
{
|
||||
// Don't retry async-specific errors
|
||||
if ($e instanceof AsyncCircuitBreakerOpenException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use same retry logic as synchronous boundary
|
||||
return true; // Simplified for this example
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay
|
||||
*/
|
||||
private function calculateRetryDelay(int $attempt): int
|
||||
{
|
||||
$baseMs = (int) $this->config->baseDelay->toMilliseconds();
|
||||
$maxMs = (int) $this->config->maxDelay->toMilliseconds();
|
||||
|
||||
return match ($this->config->retryStrategy) {
|
||||
default => min($baseMs * $attempt, $maxMs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get async circuit breaker health
|
||||
*/
|
||||
public function getCircuitHealthAsync(): AsyncPromise
|
||||
{
|
||||
return AsyncPromise::create(function () {
|
||||
if (! $this->config->circuitBreakerEnabled || ! $this->circuitBreakerManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->circuitBreakerManager->getCircuitHealth($this->boundaryName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset circuit breaker asynchronously
|
||||
*/
|
||||
public function resetCircuitAsync(): AsyncPromise
|
||||
{
|
||||
return AsyncPromise::create(function () {
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
$this->circuitBreakerManager->resetCircuit($this->boundaryName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function logFailure(Throwable $e, string $message): void
|
||||
{
|
||||
$this->log('error', $message, [
|
||||
'exception' => $e::class,
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger) {
|
||||
$context['boundary'] = $this->boundaryName;
|
||||
$context['async'] = true;
|
||||
|
||||
match ($level) {
|
||||
'debug' => $this->logger->debug("[AsyncBoundary] {$message}", $context),
|
||||
'info' => $this->logger->info("[AsyncBoundary] {$message}", $context),
|
||||
'warning' => $this->logger->warning("[AsyncBoundary] {$message}", $context),
|
||||
'error' => $this->logger->error("[AsyncBoundary] {$message}", $context),
|
||||
default => $this->logger->info("[AsyncBoundary] {$message}", $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private function publishEvent($event): void
|
||||
{
|
||||
if ($this->eventPublisher !== null) {
|
||||
$this->eventPublisher->publish($event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Async;
|
||||
|
||||
use App\Framework\Async\FiberManager;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\ErrorBoundaries\BoundaryConfig;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||
use App\Framework\EventBus\EventBus;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\StateManagerFactory;
|
||||
|
||||
/**
|
||||
* Factory for creating async error boundaries
|
||||
*/
|
||||
final readonly class AsyncErrorBoundaryFactory
|
||||
{
|
||||
public function __construct(
|
||||
private FiberManager $fiberManager,
|
||||
private Clock $clock,
|
||||
private Timer $timer,
|
||||
private ?Logger $logger = null,
|
||||
private ?StateManagerFactory $stateManagerFactory = null,
|
||||
private ?EventBus $eventBus = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async error boundary
|
||||
*/
|
||||
public function create(string $name, BoundaryConfig $config): AsyncErrorBoundary
|
||||
{
|
||||
$circuitBreakerManager = null;
|
||||
|
||||
if ($config->circuitBreakerEnabled && $this->stateManagerFactory) {
|
||||
$stateManager = $this->stateManagerFactory->createForErrorBoundary();
|
||||
$circuitBreakerManager = new BoundaryCircuitBreakerManager($stateManager, $this->logger);
|
||||
}
|
||||
|
||||
$eventPublisher = new BoundaryEventPublisher($this->eventBus, $this->logger);
|
||||
|
||||
return new AsyncErrorBoundary(
|
||||
boundaryName: $name,
|
||||
config: $config,
|
||||
fiberManager: $this->fiberManager,
|
||||
clock: $this->clock,
|
||||
timer: $this->timer,
|
||||
logger: $this->logger,
|
||||
circuitBreakerManager: $circuitBreakerManager,
|
||||
eventPublisher: $eventPublisher,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async boundary for external service calls
|
||||
*/
|
||||
public function createForExternalService(string $serviceName): AsyncErrorBoundary
|
||||
{
|
||||
return $this->create("async_external_{$serviceName}", BoundaryConfig::externalService());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async boundary for database operations
|
||||
*/
|
||||
public function createForDatabase(string $operation = 'database'): AsyncErrorBoundary
|
||||
{
|
||||
return $this->create("async_{$operation}", BoundaryConfig::database());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async boundary for background jobs
|
||||
*/
|
||||
public function createForBackgroundJob(string $jobName): AsyncErrorBoundary
|
||||
{
|
||||
return $this->create("async_job_{$jobName}", BoundaryConfig::backgroundJob());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async boundary for critical operations
|
||||
*/
|
||||
public function createForCriticalOperation(string $operationName): AsyncErrorBoundary
|
||||
{
|
||||
return $this->create("async_critical_{$operationName}", BoundaryConfig::critical());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async boundary for batch processing
|
||||
*/
|
||||
public function createForBatchProcessing(string $batchName): AsyncErrorBoundary
|
||||
{
|
||||
$config = new BoundaryConfig(
|
||||
maxRetries: 2,
|
||||
retryStrategy: \App\Framework\ErrorBoundaries\RetryStrategy::EXPONENTIAL_JITTER,
|
||||
baseDelay: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(100),
|
||||
maxDelay: \App\Framework\Core\ValueObjects\Duration::fromSeconds(5),
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 10, // Higher threshold for batch operations
|
||||
circuitBreakerTimeout: \App\Framework\Core\ValueObjects\Duration::fromMinutes(2),
|
||||
enableMetrics: true,
|
||||
maxBulkErrorRate: 0.2, // Allow 20% error rate in batch
|
||||
);
|
||||
|
||||
return $this->create("async_batch_{$batchName}", $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create async boundary for high-throughput operations
|
||||
*/
|
||||
public function createForHighThroughput(string $operationName): AsyncErrorBoundary
|
||||
{
|
||||
$config = new BoundaryConfig(
|
||||
maxRetries: 1, // Fewer retries for high throughput
|
||||
retryStrategy: \App\Framework\ErrorBoundaries\RetryStrategy::FIXED,
|
||||
baseDelay: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(50),
|
||||
maxDelay: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(200),
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 20, // Higher threshold
|
||||
circuitBreakerTimeout: \App\Framework\Core\ValueObjects\Duration::fromSeconds(30),
|
||||
enableMetrics: true,
|
||||
);
|
||||
|
||||
return $this->create("async_throughput_{$operationName}", $config);
|
||||
}
|
||||
}
|
||||
266
src/Framework/ErrorBoundaries/Async/README.md
Normal file
266
src/Framework/ErrorBoundaries/Async/README.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Async ErrorBoundary System
|
||||
|
||||
Complete async error boundary implementation using the framework's async components (`FiberManager`, `AsyncPromise`, etc.).
|
||||
|
||||
## Components
|
||||
|
||||
### Core Classes
|
||||
|
||||
- **`AsyncErrorBoundary`** - Main async error boundary using framework's `FiberManager` and `AsyncPromise`
|
||||
- **`AsyncErrorBoundaryFactory`** - Factory for creating async boundaries with different configurations
|
||||
- **`AsyncBulkResult`** - Result object for batch async operations with success/error tracking
|
||||
- **`AsyncBoundaryFailedException`** - Exception for when both operation and fallback fail
|
||||
- **`AsyncCircuitBreakerOpenException`** - Exception for circuit breaker open state
|
||||
|
||||
## Framework Integration
|
||||
|
||||
### Uses Framework Async Components
|
||||
- **`FiberManager`** - Fiber-based async execution with batching and throttling
|
||||
- **`AsyncPromise`** - Promise-based async/await pattern
|
||||
- **`Clock`/`Timer`** - Time management using framework value objects
|
||||
- **`Duration`/`Timestamp`** - Proper time value objects
|
||||
|
||||
### ErrorBoundary Integration
|
||||
- Circuit breaker state management via `BoundaryCircuitBreakerManager`
|
||||
- Event publishing via `BoundaryEventPublisher`
|
||||
- Metrics and observability support
|
||||
- Same retry strategies and configuration as sync boundaries
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Async Operation
|
||||
|
||||
```php
|
||||
$factory = new AsyncErrorBoundaryFactory($fiberManager, $clock, $timer);
|
||||
$boundary = $factory->createForExternalService('payment_api');
|
||||
|
||||
$promise = $boundary->executeAsync(
|
||||
operation: fn() => $paymentApi->processPayment($request),
|
||||
fallback: fn() => $this->createOfflinePaymentRecord($request)
|
||||
);
|
||||
|
||||
$result = $promise->await();
|
||||
```
|
||||
|
||||
### Concurrent Operations
|
||||
|
||||
```php
|
||||
$operations = [
|
||||
'user_data' => fn() => $userService->getUserData($userId),
|
||||
'preferences' => fn() => $preferenceService->getPreferences($userId),
|
||||
'notifications' => fn() => $notificationService->getUnread($userId),
|
||||
];
|
||||
|
||||
$promise = $boundary->executeConcurrent($operations);
|
||||
$results = $promise->await();
|
||||
|
||||
// Results: ['user_data' => ..., 'preferences' => ..., 'notifications' => ...]
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
|
||||
```php
|
||||
$batchBoundary = $factory->createForBatchProcessing('email_sending');
|
||||
|
||||
$promise = $batchBoundary->executeBatch(
|
||||
items: $emailQueue,
|
||||
processor: fn($email) => $mailService->send($email),
|
||||
maxConcurrency: 5
|
||||
);
|
||||
|
||||
$bulkResult = $promise->await();
|
||||
|
||||
echo "Success rate: " . $bulkResult->getSuccessRate()->toFloat() * 100 . "%\n";
|
||||
echo "Processed: {$bulkResult->successfulOperations}/{$bulkResult->totalOperations}\n";
|
||||
```
|
||||
|
||||
### Timeout Handling
|
||||
|
||||
```php
|
||||
$promise = $boundary->executeWithTimeout(
|
||||
operation: fn() => $heavyComputation->process($data),
|
||||
timeout: Duration::fromSeconds(30),
|
||||
fallback: fn() => $this->getCachedResult($data)
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $promise->await();
|
||||
} catch (AsyncTimeoutException $e) {
|
||||
// Handle timeout
|
||||
}
|
||||
```
|
||||
|
||||
### Circuit Breaker with Async
|
||||
|
||||
```php
|
||||
$promise = $boundary->executeWithCircuitBreaker(
|
||||
operation: fn() => $externalApi->call($request),
|
||||
fallback: fn() => $this->getDefaultResponse()
|
||||
);
|
||||
|
||||
$result = $promise->await();
|
||||
```
|
||||
|
||||
## Factory Configurations
|
||||
|
||||
### External Service Boundary
|
||||
```php
|
||||
$boundary = $factory->createForExternalService('payment_service');
|
||||
```
|
||||
- Retry strategy with exponential backoff
|
||||
- Circuit breaker enabled
|
||||
- Appropriate timeouts for external calls
|
||||
|
||||
### Database Boundary
|
||||
```php
|
||||
$boundary = $factory->createForDatabase('user_queries');
|
||||
```
|
||||
- Fast fail for database issues
|
||||
- Connection pooling aware
|
||||
- Optimized for database operation patterns
|
||||
|
||||
### Background Job Boundary
|
||||
```php
|
||||
$boundary = $factory->createForBackgroundJob('email_processor');
|
||||
```
|
||||
- Higher retry counts
|
||||
- Longer timeouts
|
||||
- Batch error tolerance
|
||||
|
||||
### High Throughput Boundary
|
||||
```php
|
||||
$boundary = $factory->createForHighThroughput('api_endpoints');
|
||||
```
|
||||
- Minimal retries for speed
|
||||
- Higher circuit breaker thresholds
|
||||
- Optimized for high-volume operations
|
||||
|
||||
## Async Result Types
|
||||
|
||||
### AsyncBulkResult
|
||||
|
||||
```php
|
||||
$bulkResult = $promise->await();
|
||||
|
||||
// Statistics
|
||||
$successRate = $bulkResult->getSuccessRate(); // Percentage object
|
||||
$errorRate = $bulkResult->getErrorRate(); // Percentage object
|
||||
|
||||
// Individual results
|
||||
$userResult = $bulkResult->getResult('user_1');
|
||||
$userError = $bulkResult->getError('user_1');
|
||||
|
||||
// Status checks
|
||||
$hasAnyErrors = $bulkResult->hasErrors();
|
||||
$isCompleteSuccess = $bulkResult->isCompleteSuccess();
|
||||
```
|
||||
|
||||
## Event Integration
|
||||
|
||||
Async boundaries publish the same events as sync boundaries:
|
||||
- `BoundaryExecutionSucceeded` - Successful async operation
|
||||
- `BoundaryExecutionFailed` - Failed async operation
|
||||
- `BoundaryFallbackExecuted` - Fallback was used
|
||||
- `BoundaryTimeoutOccurred` - Operation timed out
|
||||
|
||||
## Promise Chaining
|
||||
|
||||
```php
|
||||
$promise = $boundary->executeAsync(fn() => $service->getData())
|
||||
->then(fn($data) => $this->processData($data))
|
||||
->then(fn($processed) => $this->saveData($processed))
|
||||
->catch(fn($error) => $this->handleError($error))
|
||||
->finally(fn() => $this->cleanup());
|
||||
|
||||
$result = $promise->await();
|
||||
```
|
||||
|
||||
## Concurrent with Fallbacks
|
||||
|
||||
```php
|
||||
$operations = [
|
||||
'primary_data' => fn() => $primaryService->getData(),
|
||||
'backup_data' => fn() => $backupService->getData(),
|
||||
];
|
||||
|
||||
$fallbacks = [
|
||||
'primary_data' => fn() => $cache->get('primary_data'),
|
||||
'backup_data' => fn() => $cache->get('backup_data'),
|
||||
];
|
||||
|
||||
$promise = $boundary->executeConcurrent($operations, $fallbacks);
|
||||
$results = $promise->await();
|
||||
```
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
```php
|
||||
// Async health check
|
||||
$healthPromise = $boundary->getCircuitHealthAsync();
|
||||
$health = $healthPromise->await();
|
||||
|
||||
if ($health && !$health['is_healthy']) {
|
||||
$this->alerting->sendAlert("Boundary {$boundary->boundaryName} is unhealthy");
|
||||
}
|
||||
|
||||
// Async circuit reset
|
||||
$resetPromise = $boundary->resetCircuitAsync();
|
||||
$resetPromise->await();
|
||||
```
|
||||
|
||||
## Performance Benefits
|
||||
|
||||
### Framework Integration Benefits
|
||||
- **Fiber-based**: Uses PHP 8.1+ Fibers for true async execution
|
||||
- **Batching**: Framework's `FiberManager` handles optimal batching
|
||||
- **Throttling**: Built-in concurrency control
|
||||
- **Timeout Management**: Framework's timeout handling with proper cleanup
|
||||
|
||||
### Error Boundary Benefits
|
||||
- **Graceful Degradation**: Async fallbacks prevent service interruption
|
||||
- **Circuit Breaking**: Prevents cascade failures in async operations
|
||||
- **Retry Logic**: Async retry with proper delays
|
||||
- **Observability**: Events and metrics for async operations
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Concurrency Management
|
||||
```php
|
||||
// Good: Use factory methods for appropriate concurrency
|
||||
$batchBoundary = $factory->createForBatchProcessing('data_sync');
|
||||
$bulkResult = $batchBoundary->executeBatch($items, $processor, 10);
|
||||
|
||||
// Good: Throttle concurrent operations
|
||||
$results = $fiberManager->throttled($operations, 5);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```php
|
||||
// Good: Provide meaningful fallbacks
|
||||
$promise = $boundary->executeAsync(
|
||||
operation: fn() => $externalService->getData(),
|
||||
fallback: fn() => $this->getCachedData() // Always have a fallback
|
||||
);
|
||||
|
||||
// Good: Handle different error types
|
||||
try {
|
||||
$result = $promise->await();
|
||||
} catch (AsyncCircuitBreakerOpenException $e) {
|
||||
// Circuit breaker is open
|
||||
} catch (AsyncBoundaryFailedException $e) {
|
||||
// Both operation and fallback failed
|
||||
$originalError = $e->getOriginalException();
|
||||
$fallbackError = $e->getFallbackException();
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
```php
|
||||
// Good: Use appropriate configurations
|
||||
$highThroughputBoundary = $factory->createForHighThroughput('api_calls');
|
||||
$batchBoundary = $factory->createForBatchProcessing('data_processing');
|
||||
|
||||
// Good: Monitor performance
|
||||
$bulkResult = $batchBoundary->executeBatch($items, $processor);
|
||||
$this->metrics->record('batch_success_rate', $bulkResult->getSuccessRate());
|
||||
```
|
||||
158
src/Framework/ErrorBoundaries/BoundaryConfig.php
Normal file
158
src/Framework/ErrorBoundaries/BoundaryConfig.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* Configuration for Error Boundaries
|
||||
*/
|
||||
final readonly class BoundaryConfig
|
||||
{
|
||||
public readonly Duration $baseDelay;
|
||||
|
||||
public readonly Duration $maxDelay;
|
||||
|
||||
public readonly Duration $circuitBreakerTimeout;
|
||||
|
||||
public function __construct(
|
||||
public int $maxRetries = 3,
|
||||
public RetryStrategy $retryStrategy = RetryStrategy::EXPONENTIAL_JITTER,
|
||||
?Duration $baseDelay = null,
|
||||
?Duration $maxDelay = null,
|
||||
public bool $circuitBreakerEnabled = false,
|
||||
public int $circuitBreakerThreshold = 5, // failures before opening circuit
|
||||
?Duration $circuitBreakerTimeout = null,
|
||||
public float $maxBulkErrorRate = 0.5, // Stop bulk processing if error rate exceeds this
|
||||
public bool $enableMetrics = true,
|
||||
public bool $enableTracing = false,
|
||||
) {
|
||||
$this->baseDelay = $baseDelay ?? Duration::fromMilliseconds(100);
|
||||
$this->maxDelay = $maxDelay ?? Duration::fromSeconds(5);
|
||||
$this->circuitBreakerTimeout = $circuitBreakerTimeout ?? Duration::fromMinutes(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration for critical operations
|
||||
*/
|
||||
public static function critical(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 5,
|
||||
retryStrategy: RetryStrategy::EXPONENTIAL_JITTER,
|
||||
baseDelay: Duration::fromMilliseconds(50),
|
||||
maxDelay: Duration::fromSeconds(2),
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 3,
|
||||
circuitBreakerTimeout: Duration::fromSeconds(30),
|
||||
maxBulkErrorRate: 0.1,
|
||||
enableMetrics: true,
|
||||
enableTracing: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration for external services
|
||||
*/
|
||||
public static function externalService(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 3,
|
||||
retryStrategy: RetryStrategy::EXPONENTIAL_JITTER,
|
||||
baseDelay: Duration::fromMilliseconds(200),
|
||||
maxDelay: Duration::fromSeconds(10),
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 5,
|
||||
circuitBreakerTimeout: Duration::fromMinutes(2),
|
||||
maxBulkErrorRate: 0.3,
|
||||
enableMetrics: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration for database operations
|
||||
*/
|
||||
public static function database(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 2,
|
||||
retryStrategy: RetryStrategy::EXPONENTIAL,
|
||||
baseDelay: Duration::fromMilliseconds(100),
|
||||
maxDelay: Duration::fromSeconds(1),
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 3,
|
||||
circuitBreakerTimeout: Duration::fromMinutes(1),
|
||||
maxBulkErrorRate: 0.2,
|
||||
enableMetrics: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration for UI components
|
||||
*/
|
||||
public static function ui(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 1,
|
||||
retryStrategy: RetryStrategy::FIXED,
|
||||
baseDelay: Duration::fromMilliseconds(50),
|
||||
maxDelay: Duration::fromMilliseconds(200),
|
||||
circuitBreakerEnabled: false,
|
||||
maxBulkErrorRate: 0.8, // UI can tolerate more partial failures
|
||||
enableMetrics: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration for background jobs
|
||||
*/
|
||||
public static function backgroundJob(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 5,
|
||||
retryStrategy: RetryStrategy::EXPONENTIAL_JITTER,
|
||||
baseDelay: Duration::fromSeconds(1),
|
||||
maxDelay: Duration::fromMinutes(1),
|
||||
circuitBreakerEnabled: false, // Jobs will be retried by queue system
|
||||
maxBulkErrorRate: 0.1,
|
||||
enableMetrics: true,
|
||||
enableTracing: true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration with no retries (fail fast)
|
||||
*/
|
||||
public static function failFast(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 0,
|
||||
retryStrategy: RetryStrategy::FIXED,
|
||||
baseDelay: Duration::zero(),
|
||||
maxDelay: Duration::zero(),
|
||||
circuitBreakerEnabled: false,
|
||||
circuitBreakerTimeout: Duration::zero(), // No timeout needed for fail fast
|
||||
maxBulkErrorRate: 0.0,
|
||||
enableMetrics: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates configuration for development/testing
|
||||
*/
|
||||
public static function development(): self
|
||||
{
|
||||
return new self(
|
||||
maxRetries: 1,
|
||||
retryStrategy: RetryStrategy::FIXED,
|
||||
baseDelay: Duration::fromMilliseconds(10),
|
||||
maxDelay: Duration::fromMilliseconds(100),
|
||||
circuitBreakerEnabled: false,
|
||||
maxBulkErrorRate: 1.0, // Allow all errors in development
|
||||
enableMetrics: true,
|
||||
enableTracing: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
135
src/Framework/ErrorBoundaries/BoundaryFailedException.php
Normal file
135
src/Framework/ErrorBoundaries/BoundaryFailedException.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception thrown when error boundary fails
|
||||
*/
|
||||
final class BoundaryFailedException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly string $boundaryName,
|
||||
private readonly ?Throwable $originalException = null,
|
||||
private readonly ?Throwable $fallbackException = null,
|
||||
int $code = 0,
|
||||
?Throwable $previous = null
|
||||
) {
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: ExceptionContext::forOperation('error_boundary_failure', 'ErrorBoundary')
|
||||
->withData([
|
||||
'boundary_name' => $boundaryName,
|
||||
'double_failure' => $originalException !== null && $fallbackException !== null,
|
||||
'original_error' => $originalException?->getMessage(),
|
||||
'fallback_error' => $fallbackException?->getMessage(),
|
||||
]),
|
||||
code: $code,
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the boundary name where failure occurred
|
||||
*/
|
||||
public function getBoundaryName(): string
|
||||
{
|
||||
return $this->boundaryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original exception that caused the boundary to trigger
|
||||
*/
|
||||
public function getOriginalException(): ?Throwable
|
||||
{
|
||||
return $this->originalException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fallback exception (if fallback also failed)
|
||||
*/
|
||||
public function getFallbackException(): ?Throwable
|
||||
{
|
||||
return $this->fallbackException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this was a double failure (both operation and fallback failed)
|
||||
*/
|
||||
public function isDoubleFailure(): bool
|
||||
{
|
||||
return $this->originalException !== null && $this->fallbackException !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed error information
|
||||
*/
|
||||
public function getDetailedMessage(): string
|
||||
{
|
||||
$message = "Error boundary '{$this->boundaryName}' failed: {$this->getMessage()}";
|
||||
|
||||
if ($this->originalException) {
|
||||
$message .= "\nOriginal error: " . $this->originalException->getMessage();
|
||||
}
|
||||
|
||||
if ($this->fallbackException) {
|
||||
$message .= "\nFallback error: " . $this->fallbackException->getMessage();
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all exceptions in the failure chain
|
||||
*
|
||||
* @return Throwable[]
|
||||
*/
|
||||
public function getAllExceptions(): array
|
||||
{
|
||||
$exceptions = [$this];
|
||||
|
||||
if ($this->originalException) {
|
||||
$exceptions[] = $this->originalException;
|
||||
}
|
||||
|
||||
if ($this->fallbackException) {
|
||||
$exceptions[] = $this->fallbackException;
|
||||
}
|
||||
|
||||
return $exceptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for single failure
|
||||
*/
|
||||
public static function singleFailure(string $boundaryName, Throwable $originalException): self
|
||||
{
|
||||
return new self(
|
||||
"Operation failed in boundary '{$boundaryName}'",
|
||||
$boundaryName,
|
||||
$originalException
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception for double failure (operation and fallback both failed)
|
||||
*/
|
||||
public static function doubleFailure(
|
||||
string $boundaryName,
|
||||
Throwable $originalException,
|
||||
Throwable $fallbackException
|
||||
): self {
|
||||
return new self(
|
||||
"Both operation and fallback failed in boundary '{$boundaryName}'",
|
||||
$boundaryName,
|
||||
$originalException,
|
||||
$fallbackException
|
||||
);
|
||||
}
|
||||
}
|
||||
206
src/Framework/ErrorBoundaries/BoundaryResult.php
Normal file
206
src/Framework/ErrorBoundaries/BoundaryResult.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Result wrapper for error boundary operations
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
final readonly class BoundaryResult
|
||||
{
|
||||
private function __construct(
|
||||
private mixed $value,
|
||||
private ?Throwable $error,
|
||||
private bool $isSuccess,
|
||||
private ?string $boundaryName = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create successful result
|
||||
*
|
||||
* @template U
|
||||
* @param U $value
|
||||
* @return BoundaryResult<U>
|
||||
*/
|
||||
public static function success(mixed $value): self
|
||||
{
|
||||
return new self($value, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failed result
|
||||
*
|
||||
* @template U
|
||||
* @param Throwable $error
|
||||
* @param string|null $boundaryName
|
||||
* @return BoundaryResult<U>
|
||||
*/
|
||||
public static function failure(Throwable $error, ?string $boundaryName = null): self
|
||||
{
|
||||
return new self(null, $error, false, $boundaryName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operation was successful
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->isSuccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operation failed
|
||||
*/
|
||||
public function isFailure(): bool
|
||||
{
|
||||
return ! $this->isSuccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result value (only if successful)
|
||||
*
|
||||
* @return T
|
||||
* @throws BoundaryFailedException
|
||||
*/
|
||||
public function getValue(): mixed
|
||||
{
|
||||
if (! $this->isSuccess) {
|
||||
throw new BoundaryFailedException(
|
||||
'Cannot get value from failed result',
|
||||
$this->boundaryName ?? 'unknown',
|
||||
$this->error
|
||||
);
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error (only if failed)
|
||||
*/
|
||||
public function getError(): ?Throwable
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get boundary name
|
||||
*/
|
||||
public function getBoundaryName(): ?string
|
||||
{
|
||||
return $this->boundaryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value or return default if failed
|
||||
*
|
||||
* @param T $default
|
||||
* @return T
|
||||
*/
|
||||
public function getValueOrDefault(mixed $default): mixed
|
||||
{
|
||||
return $this->isSuccess ? $this->value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value or execute fallback function if failed
|
||||
*
|
||||
* @param callable(): T $fallback
|
||||
* @return T
|
||||
*/
|
||||
public function getValueOrElse(callable $fallback): mixed
|
||||
{
|
||||
return $this->isSuccess ? $this->value : $fallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the result if successful
|
||||
*
|
||||
* @template U
|
||||
* @param callable(T): U $mapper
|
||||
* @return BoundaryResult<U>
|
||||
*/
|
||||
public function map(callable $mapper): self
|
||||
{
|
||||
if (! $this->isSuccess) {
|
||||
return new self(null, $this->error, false, $this->boundaryName);
|
||||
}
|
||||
|
||||
try {
|
||||
$transformed = $mapper($this->value);
|
||||
|
||||
return new self($transformed, null, true, $this->boundaryName);
|
||||
} catch (Throwable $e) {
|
||||
return new self(null, $e, false, $this->boundaryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain another operation if current result is successful
|
||||
*
|
||||
* @template U
|
||||
* @param callable(T): BoundaryResult<U> $mapper
|
||||
* @return BoundaryResult<U>
|
||||
*/
|
||||
public function flatMap(callable $mapper): self
|
||||
{
|
||||
if (! $this->isSuccess) {
|
||||
return new self(null, $this->error, false, $this->boundaryName);
|
||||
}
|
||||
|
||||
try {
|
||||
return $mapper($this->value);
|
||||
} catch (Throwable $e) {
|
||||
return new self(null, $e, false, $this->boundaryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback if result is successful
|
||||
*
|
||||
* @param callable(T): void $callback
|
||||
* @return self
|
||||
*/
|
||||
public function onSuccess(callable $callback): self
|
||||
{
|
||||
if ($this->isSuccess) {
|
||||
$callback($this->value);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback if result failed
|
||||
*
|
||||
* @param callable(Throwable): void $callback
|
||||
* @return self
|
||||
*/
|
||||
public function onFailure(callable $callback): self
|
||||
{
|
||||
if (! $this->isSuccess && $this->error) {
|
||||
$callback($this->error);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'success' => $this->isSuccess,
|
||||
'value' => $this->isSuccess ? $this->value : null,
|
||||
'error' => $this->error?->getMessage(),
|
||||
'boundary' => $this->boundaryName,
|
||||
];
|
||||
}
|
||||
}
|
||||
117
src/Framework/ErrorBoundaries/BoundaryTimeoutException.php
Normal file
117
src/Framework/ErrorBoundaries/BoundaryTimeoutException.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Exception thrown when operation exceeds timeout
|
||||
*/
|
||||
final class BoundaryTimeoutException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly string $boundaryName,
|
||||
private readonly float $executionTime,
|
||||
private readonly float $timeoutLimit = 0.0,
|
||||
int $code = 0
|
||||
) {
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: ExceptionContext::forOperation('error_boundary_timeout', 'ErrorBoundary')
|
||||
->withData([
|
||||
'boundary_name' => $boundaryName,
|
||||
'execution_time' => $executionTime,
|
||||
'timeout_limit' => $timeoutLimit,
|
||||
'overage' => max(0.0, $executionTime - $timeoutLimit),
|
||||
'hard_timeout' => $timeoutLimit > 0.0 && $executionTime > $timeoutLimit,
|
||||
]),
|
||||
code: $code
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the boundary name where timeout occurred
|
||||
*/
|
||||
public function getBoundaryName(): string
|
||||
{
|
||||
return $this->boundaryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual execution time in seconds
|
||||
*/
|
||||
public function getExecutionTime(): float
|
||||
{
|
||||
return $this->executionTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timeout limit in seconds
|
||||
*/
|
||||
public function getTimeoutLimit(): float
|
||||
{
|
||||
return $this->timeoutLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get how much the execution exceeded the timeout
|
||||
*/
|
||||
public function getOverage(): float
|
||||
{
|
||||
return max(0.0, $this->executionTime - $this->timeoutLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this was a hard timeout (exceeded limit)
|
||||
*/
|
||||
public function isHardTimeout(): bool
|
||||
{
|
||||
return $this->timeoutLimit > 0.0 && $this->executionTime > $this->timeoutLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable timeout information
|
||||
*/
|
||||
public function getTimeoutInfo(): string
|
||||
{
|
||||
$info = "Boundary '{$this->boundaryName}' timed out after {$this->executionTime}s";
|
||||
|
||||
if ($this->timeoutLimit > 0.0) {
|
||||
$info .= " (limit: {$this->timeoutLimit}s, overage: " . $this->getOverage() . "s)";
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeout exception with limit
|
||||
*/
|
||||
public static function withLimit(
|
||||
string $boundaryName,
|
||||
float $executionTime,
|
||||
float $timeoutLimit
|
||||
): self {
|
||||
return new self(
|
||||
"Operation in boundary '{$boundaryName}' exceeded timeout of {$timeoutLimit}s (took {$executionTime}s)",
|
||||
$boundaryName,
|
||||
$executionTime,
|
||||
$timeoutLimit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeout exception without specific limit
|
||||
*/
|
||||
public static function withoutLimit(string $boundaryName, float $executionTime): self
|
||||
{
|
||||
return new self(
|
||||
"Operation in boundary '{$boundaryName}' timed out after {$executionTime}s",
|
||||
$boundaryName,
|
||||
$executionTime
|
||||
);
|
||||
}
|
||||
}
|
||||
269
src/Framework/ErrorBoundaries/BulkResult.php
Normal file
269
src/Framework/ErrorBoundaries/BulkResult.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Result of bulk operations with partial failure support
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
final readonly class BulkResult
|
||||
{
|
||||
/**
|
||||
* @param array<string|int, mixed> $results Successful results indexed by original key
|
||||
* @param array<string|int, Throwable> $errors Failed operations indexed by original key
|
||||
* @param int $processedCount Number of items actually processed
|
||||
* @param int $totalCount Total number of items in the batch
|
||||
*/
|
||||
public function __construct(
|
||||
private array $results,
|
||||
private array $errors,
|
||||
private int $processedCount,
|
||||
private int $totalCount
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all successful results
|
||||
*/
|
||||
public function getResults(): array
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all errors
|
||||
*
|
||||
* @return array<string|int, Throwable>
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of successful operations
|
||||
*/
|
||||
public function getSuccessCount(): int
|
||||
{
|
||||
return count($this->results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of failed operations
|
||||
*/
|
||||
public function getErrorCount(): int
|
||||
{
|
||||
return count($this->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of items actually processed (may be less than total if stopped early)
|
||||
*/
|
||||
public function getProcessedCount(): int
|
||||
{
|
||||
return $this->processedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of items in the batch
|
||||
*/
|
||||
public function getTotalCount(): int
|
||||
{
|
||||
return $this->totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of items skipped (not processed due to early termination)
|
||||
*/
|
||||
public function getSkippedCount(): int
|
||||
{
|
||||
return $this->totalCount - $this->processedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all operations were successful
|
||||
*/
|
||||
public function isCompleteSuccess(): bool
|
||||
{
|
||||
return count($this->errors) === 0 && $this->processedCount === $this->totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any operations were successful
|
||||
*/
|
||||
public function hasSuccesses(): bool
|
||||
{
|
||||
return count($this->results) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any operations failed
|
||||
*/
|
||||
public function hasErrors(): bool
|
||||
{
|
||||
return count($this->errors) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if processing was terminated early
|
||||
*/
|
||||
public function wasTerminatedEarly(): bool
|
||||
{
|
||||
return $this->processedCount < $this->totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success rate as percentage
|
||||
*/
|
||||
public function getSuccessRate(): float
|
||||
{
|
||||
if ($this->processedCount === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (count($this->results) / $this->processedCount) * 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error rate as percentage
|
||||
*/
|
||||
public function getErrorRate(): float
|
||||
{
|
||||
if ($this->processedCount === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (count($this->errors) / $this->processedCount) * 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get result for specific key
|
||||
*/
|
||||
public function getResult(string|int $key): mixed
|
||||
{
|
||||
return $this->results[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error for specific key
|
||||
*/
|
||||
public function getError(string|int $key): ?Throwable
|
||||
{
|
||||
return $this->errors[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if specific key was successful
|
||||
*/
|
||||
public function wasSuccessful(string|int $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if specific key failed
|
||||
*/
|
||||
public function hasFailed(string|int $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys that were successful
|
||||
*/
|
||||
public function getSuccessfulKeys(): array
|
||||
{
|
||||
return array_keys($this->results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys that failed
|
||||
*/
|
||||
public function getFailedKeys(): array
|
||||
{
|
||||
return array_keys($this->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter results by condition
|
||||
*
|
||||
* @param callable(mixed, string|int): bool $predicate
|
||||
*/
|
||||
public function filterResults(callable $predicate): array
|
||||
{
|
||||
return array_filter($this->results, $predicate, ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform successful results
|
||||
*
|
||||
* @template U
|
||||
* @param callable(mixed, string|int): U $transformer
|
||||
* @return array<string|int, U>
|
||||
*/
|
||||
public function mapResults(callable $transformer): array
|
||||
{
|
||||
$mapped = [];
|
||||
|
||||
foreach ($this->results as $key => $result) {
|
||||
$mapped[$key] = $transformer($result, $key);
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'total' => $this->totalCount,
|
||||
'processed' => $this->processedCount,
|
||||
'successful' => $this->getSuccessCount(),
|
||||
'failed' => $this->getErrorCount(),
|
||||
'skipped' => $this->getSkippedCount(),
|
||||
'success_rate' => round($this->getSuccessRate(), 2),
|
||||
'error_rate' => round($this->getErrorRate(), 2),
|
||||
'completed' => $this->isCompleteSuccess(),
|
||||
'terminated_early' => $this->wasTerminatedEarly(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'results' => $this->results,
|
||||
'errors' => array_map(fn (Throwable $e) => $e->getMessage(), $this->errors),
|
||||
'summary' => $this->getSummary(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty result
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([], [], 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from arrays
|
||||
*/
|
||||
public static function create(
|
||||
array $results,
|
||||
array $errors,
|
||||
int $processedCount,
|
||||
int $totalCount
|
||||
): self {
|
||||
return new self($results, $errors, $processedCount, $totalCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\CircuitBreaker;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\ErrorBoundaries\BoundaryConfig;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\StateManager;
|
||||
|
||||
/**
|
||||
* Circuit breaker manager for error boundaries using generic state management
|
||||
*/
|
||||
final readonly class BoundaryCircuitBreakerManager
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* @var StateManager<BoundaryCircuitBreakerState>
|
||||
*/
|
||||
private StateManager $stateManager,
|
||||
private ?Logger $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is open for given boundary
|
||||
*/
|
||||
public function isCircuitOpen(string $boundaryName, BoundaryConfig $config): bool
|
||||
{
|
||||
$state = $this->getCircuitState($boundaryName);
|
||||
|
||||
if ($state->isOpen()) {
|
||||
// Check if circuit should transition to half-open
|
||||
if ($this->shouldTransitionToHalfOpen($state, $config)) {
|
||||
$newState = $state->transitionToHalfOpen();
|
||||
$this->stateManager->setState($boundaryName, $newState);
|
||||
$this->log('info', "Circuit transitioned to HALF_OPEN for boundary: {$boundaryName}");
|
||||
|
||||
return false; // Allow operations in half-open state
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful operation
|
||||
*/
|
||||
public function recordSuccess(string $boundaryName, BoundaryConfig $config): void
|
||||
{
|
||||
$newState = $this->stateManager->updateState(
|
||||
$boundaryName,
|
||||
function (?BoundaryCircuitBreakerState $currentState) use ($config, $boundaryName): BoundaryCircuitBreakerState {
|
||||
$state = $currentState ?? new BoundaryCircuitBreakerState();
|
||||
$newState = $state->recordSuccess();
|
||||
|
||||
// If in half-open state and enough successes, close the circuit
|
||||
if ($state->isHalfOpen() && $newState->meetsSuccessThreshold($config->successThreshold ?? 3)) {
|
||||
$newState = $newState->transitionToClosed();
|
||||
$this->log('info', "Circuit transitioned to CLOSED for boundary: {$boundaryName}");
|
||||
}
|
||||
|
||||
return $newState;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed operation
|
||||
*/
|
||||
public function recordFailure(string $boundaryName, BoundaryConfig $config): void
|
||||
{
|
||||
$newState = $this->stateManager->updateState(
|
||||
$boundaryName,
|
||||
function (?BoundaryCircuitBreakerState $currentState) use ($config, $boundaryName): BoundaryCircuitBreakerState {
|
||||
$state = $currentState ?? new BoundaryCircuitBreakerState();
|
||||
$newState = $state->recordFailure();
|
||||
|
||||
// Check if failure threshold is exceeded
|
||||
if ($newState->exceedsFailureThreshold($config->circuitBreakerThreshold)) {
|
||||
$newState = $newState->transitionToOpen();
|
||||
$this->log('warning', "Circuit transitioned to OPEN for boundary: {$boundaryName} after {$config->circuitBreakerThreshold} failures");
|
||||
}
|
||||
|
||||
// If in half-open state, any failure should open the circuit
|
||||
if ($state->isHalfOpen()) {
|
||||
$newState = $newState->transitionToOpen();
|
||||
$this->log('warning', "Circuit transitioned to OPEN for boundary: {$boundaryName} due to failure in HALF_OPEN state");
|
||||
}
|
||||
|
||||
return $newState;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit state
|
||||
*/
|
||||
public function getCircuitState(string $boundaryName): BoundaryCircuitBreakerState
|
||||
{
|
||||
return $this->stateManager->getState($boundaryName) ?? new BoundaryCircuitBreakerState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset circuit breaker state
|
||||
*/
|
||||
public function resetCircuit(string $boundaryName): void
|
||||
{
|
||||
$newState = new BoundaryCircuitBreakerState(); // Default closed state
|
||||
$this->stateManager->setState($boundaryName, $newState);
|
||||
$this->log('info', "Circuit reset for boundary: {$boundaryName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all circuit breaker states
|
||||
*/
|
||||
public function getAllCircuitStates(): array
|
||||
{
|
||||
$states = $this->stateManager->getAllStates();
|
||||
$result = [];
|
||||
|
||||
foreach ($states as $boundaryName => $state) {
|
||||
$result[$boundaryName] = $this->getCircuitHealth($boundaryName);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check circuit health for monitoring
|
||||
*/
|
||||
public function getCircuitHealth(string $boundaryName): array
|
||||
{
|
||||
$state = $this->getCircuitState($boundaryName);
|
||||
|
||||
return [
|
||||
'boundary_name' => $boundaryName,
|
||||
'state' => $state->state->value,
|
||||
'state_description' => $state->state->getDescription(),
|
||||
'failure_count' => $state->failureCount,
|
||||
'success_count' => $state->successCount,
|
||||
'last_failure_time' => $state->lastFailureTime?->toIsoString(),
|
||||
'opened_at' => $state->openedAt?->toIsoString(),
|
||||
'half_open_attempts' => $state->halfOpenAttempts,
|
||||
'is_healthy' => $state->isClosed(),
|
||||
'severity' => $state->state->getSeverity(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state manager statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return $this->stateManager->getStatistics()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment half-open attempts
|
||||
*/
|
||||
public function incrementHalfOpenAttempts(string $boundaryName): void
|
||||
{
|
||||
$this->stateManager->updateState(
|
||||
$boundaryName,
|
||||
function (?BoundaryCircuitBreakerState $currentState): BoundaryCircuitBreakerState {
|
||||
$state = $currentState ?? new BoundaryCircuitBreakerState();
|
||||
|
||||
return $state->incrementHalfOpenAttempts();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldTransitionToHalfOpen(BoundaryCircuitBreakerState $state, BoundaryConfig $config): bool
|
||||
{
|
||||
if (! $state->isOpen() || $state->openedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$timeSinceOpened = Timestamp::now()->diff($state->openedAt);
|
||||
|
||||
return $timeSinceOpened->isGreaterThan($config->circuitBreakerTimeout);
|
||||
}
|
||||
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context['component'] = 'BoundaryCircuitBreakerManager';
|
||||
|
||||
match ($level) {
|
||||
'debug' => $this->logger->debug("[ErrorBoundary] {$message}", $context),
|
||||
'info' => $this->logger->info("[ErrorBoundary] {$message}", $context),
|
||||
'warning' => $this->logger->warning("[ErrorBoundary] {$message}", $context),
|
||||
'error' => $this->logger->error("[ErrorBoundary] {$message}", $context),
|
||||
default => $this->logger->info("[ErrorBoundary] {$message}", $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\CircuitBreaker;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\StateManagement\SerializableState;
|
||||
|
||||
/**
|
||||
* Value object representing circuit breaker state for error boundaries
|
||||
*/
|
||||
final readonly class BoundaryCircuitBreakerState implements SerializableState
|
||||
{
|
||||
public function __construct(
|
||||
public int $failureCount = 0,
|
||||
public int $successCount = 0,
|
||||
public ?Timestamp $lastFailureTime = null,
|
||||
public ?Timestamp $openedAt = null,
|
||||
public BoundaryCircuitState $state = BoundaryCircuitState::CLOSED,
|
||||
public int $halfOpenAttempts = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is open
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->state === BoundaryCircuitState::OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is closed
|
||||
*/
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->state === BoundaryCircuitState::CLOSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is half-open
|
||||
*/
|
||||
public function isHalfOpen(): bool
|
||||
{
|
||||
return $this->state === BoundaryCircuitState::HALF_OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failure and return new state
|
||||
*/
|
||||
public function recordFailure(): self
|
||||
{
|
||||
return new self(
|
||||
failureCount: $this->failureCount + 1,
|
||||
successCount: $this->successCount,
|
||||
lastFailureTime: Timestamp::now(),
|
||||
openedAt: $this->openedAt,
|
||||
state: $this->state,
|
||||
halfOpenAttempts: $this->halfOpenAttempts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a success and return new state
|
||||
*/
|
||||
public function recordSuccess(): self
|
||||
{
|
||||
return new self(
|
||||
failureCount: 0, // Reset failure count on success
|
||||
successCount: $this->successCount + 1,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
openedAt: null, // Reset opened time
|
||||
state: $this->state,
|
||||
halfOpenAttempts: 0, // Reset half-open attempts
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to OPEN state
|
||||
*/
|
||||
public function transitionToOpen(): self
|
||||
{
|
||||
return new self(
|
||||
failureCount: $this->failureCount,
|
||||
successCount: $this->successCount,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
openedAt: Timestamp::now(),
|
||||
state: BoundaryCircuitState::OPEN,
|
||||
halfOpenAttempts: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to HALF_OPEN state
|
||||
*/
|
||||
public function transitionToHalfOpen(): self
|
||||
{
|
||||
return new self(
|
||||
failureCount: $this->failureCount,
|
||||
successCount: $this->successCount,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
openedAt: $this->openedAt,
|
||||
state: BoundaryCircuitState::HALF_OPEN,
|
||||
halfOpenAttempts: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to CLOSED state
|
||||
*/
|
||||
public function transitionToClosed(): self
|
||||
{
|
||||
return new self(
|
||||
failureCount: 0, // Reset failure count
|
||||
successCount: $this->successCount,
|
||||
lastFailureTime: null, // Reset last failure time
|
||||
openedAt: null, // Reset opened time
|
||||
state: BoundaryCircuitState::CLOSED,
|
||||
halfOpenAttempts: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment half-open attempts
|
||||
*/
|
||||
public function incrementHalfOpenAttempts(): self
|
||||
{
|
||||
return new self(
|
||||
failureCount: $this->failureCount,
|
||||
successCount: $this->successCount,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
openedAt: $this->openedAt,
|
||||
state: $this->state,
|
||||
halfOpenAttempts: $this->halfOpenAttempts + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if failure threshold is exceeded
|
||||
*/
|
||||
public function exceedsFailureThreshold(int $threshold): bool
|
||||
{
|
||||
return $this->failureCount >= $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if success threshold is met for half-open state
|
||||
*/
|
||||
public function meetsSuccessThreshold(int $threshold): bool
|
||||
{
|
||||
return $this->successCount >= $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if half-open attempts exceed maximum
|
||||
*/
|
||||
public function exceedsHalfOpenAttempts(int $maxAttempts): bool
|
||||
{
|
||||
return $this->halfOpenAttempts >= $maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'failure_count' => $this->failureCount,
|
||||
'success_count' => $this->successCount,
|
||||
'last_failure_time' => $this->lastFailureTime?->toFloat(),
|
||||
'opened_at' => $this->openedAt?->toFloat(),
|
||||
'state' => $this->state->value,
|
||||
'half_open_attempts' => $this->halfOpenAttempts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array (deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
return new self(
|
||||
failureCount: $data['failure_count'] ?? 0,
|
||||
successCount: $data['success_count'] ?? 0,
|
||||
lastFailureTime: isset($data['last_failure_time'])
|
||||
? Timestamp::fromFloat($data['last_failure_time'])
|
||||
: null,
|
||||
openedAt: isset($data['opened_at'])
|
||||
? Timestamp::fromFloat($data['opened_at'])
|
||||
: null,
|
||||
state: BoundaryCircuitState::from($data['state'] ?? 'closed'),
|
||||
halfOpenAttempts: $data['half_open_attempts'] ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\CircuitBreaker;
|
||||
|
||||
/**
|
||||
* Circuit breaker states for error boundaries
|
||||
*/
|
||||
enum BoundaryCircuitState: string
|
||||
{
|
||||
case CLOSED = 'closed';
|
||||
case OPEN = 'open';
|
||||
case HALF_OPEN = 'half_open';
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CLOSED => 'Circuit is closed - operations are allowed',
|
||||
self::OPEN => 'Circuit is open - operations are blocked',
|
||||
self::HALF_OPEN => 'Circuit is half-open - limited operations are allowed for testing',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operations are allowed in this state
|
||||
*/
|
||||
public function allowsOperations(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::CLOSED => true,
|
||||
self::OPEN => false,
|
||||
self::HALF_OPEN => true, // Limited operations allowed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the severity level of this state
|
||||
*/
|
||||
public function getSeverity(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CLOSED => 'info',
|
||||
self::OPEN => 'error',
|
||||
self::HALF_OPEN => 'warning',
|
||||
};
|
||||
}
|
||||
}
|
||||
279
src/Framework/ErrorBoundaries/Commands/BoundaryCommand.php
Normal file
279
src/Framework/ErrorBoundaries/Commands/BoundaryCommand.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\ErrorBoundaries\BoundaryConfig;
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
|
||||
|
||||
/**
|
||||
* Console commands for error boundary management
|
||||
*/
|
||||
final readonly class BoundaryCommand
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorBoundaryFactory $boundaryFactory
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'boundary:test',
|
||||
description: 'Test error boundary functionality'
|
||||
)]
|
||||
public function test(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$testType = $args[0] ?? 'basic';
|
||||
|
||||
$output->writeLine('<info>Testing Error Boundary functionality...</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
return match ($testType) {
|
||||
'basic' => $this->testBasicFunctionality($output),
|
||||
'retry' => $this->testRetryStrategies($output),
|
||||
'circuit' => $this->testCircuitBreaker($output),
|
||||
'bulk' => $this->testBulkOperations($output),
|
||||
'timeout' => $this->testTimeout($output),
|
||||
default => $this->showTestOptions($output),
|
||||
};
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'boundary:stats',
|
||||
description: 'Show error boundary statistics'
|
||||
)]
|
||||
public function stats(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<info>Error Boundary Statistics</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
// Get circuit breaker states
|
||||
$states = $this->getCircuitBreakerStates();
|
||||
|
||||
if (empty($states)) {
|
||||
$output->writeLine('<comment>No active circuit breakers found.</comment>');
|
||||
} else {
|
||||
$output->writeLine('<comment>Active Circuit Breakers:</comment>');
|
||||
foreach ($states as $boundary => $state) {
|
||||
$statusIcon = $state['failures'] >= 5 ? '🔴' : '🟢';
|
||||
$output->writeLine(" {$statusIcon} <comment>{$boundary}</comment>: {$state['failures']} failures");
|
||||
|
||||
if ($state['failures'] > 0) {
|
||||
$lastFailure = date('Y-m-d H:i:s', $state['opened_at']);
|
||||
$output->writeLine(" Last failure: {$lastFailure}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'boundary:reset',
|
||||
description: 'Reset circuit breaker states'
|
||||
)]
|
||||
public function reset(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$boundaryName = $args[0] ?? null;
|
||||
|
||||
if ($boundaryName) {
|
||||
$this->resetCircuitBreaker($boundaryName);
|
||||
$output->writeLine("<info>Reset circuit breaker for boundary: {$boundaryName}</info>");
|
||||
} else {
|
||||
$this->resetAllCircuitBreakers();
|
||||
$output->writeLine('<info>Reset all circuit breakers</info>');
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function testBasicFunctionality(ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<comment>Testing basic error boundary functionality...</comment>');
|
||||
|
||||
$boundary = $this->boundaryFactory->create('test_basic', BoundaryConfig::development());
|
||||
|
||||
// Test successful operation
|
||||
$result = $boundary->execute(
|
||||
operation: fn () => 'success',
|
||||
fallback: fn () => 'fallback'
|
||||
);
|
||||
|
||||
$successIcon = $result === 'success' ? '✅' : '❌';
|
||||
$output->writeLine(" {$successIcon} Success case: {$result}");
|
||||
|
||||
// Test fallback
|
||||
$result = $boundary->execute(
|
||||
operation: fn () => throw new \Exception('Test failure'),
|
||||
fallback: fn () => 'fallback_executed'
|
||||
);
|
||||
|
||||
$fallbackIcon = $result === 'fallback_executed' ? '✅' : '❌';
|
||||
$output->writeLine(" {$fallbackIcon} Fallback case: {$result}");
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function testRetryStrategies(ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<comment>Testing retry strategies...</comment>');
|
||||
|
||||
$strategies = [
|
||||
'FIXED' => BoundaryConfig::development(),
|
||||
'EXPONENTIAL' => new BoundaryConfig(maxRetries: 3, retryStrategy: \App\Framework\ErrorBoundaries\RetryStrategy::EXPONENTIAL),
|
||||
'EXPONENTIAL_JITTER' => new BoundaryConfig(maxRetries: 3, retryStrategy: \App\Framework\ErrorBoundaries\RetryStrategy::EXPONENTIAL_JITTER),
|
||||
];
|
||||
|
||||
foreach ($strategies as $name => $config) {
|
||||
$boundary = $this->boundaryFactory->create("test_retry_{$name}", $config);
|
||||
|
||||
$attemptCount = 0;
|
||||
$result = $boundary->executeOptional(function () use (&$attemptCount) {
|
||||
$attemptCount++;
|
||||
if ($attemptCount < 3) {
|
||||
throw new \Exception("Attempt {$attemptCount}");
|
||||
}
|
||||
|
||||
return "Success after {$attemptCount} attempts";
|
||||
});
|
||||
|
||||
$icon = $result !== null ? '✅' : '❌';
|
||||
$output->writeLine(" {$icon} {$name}: {$result}");
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function testCircuitBreaker(ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<comment>Testing circuit breaker...</comment>');
|
||||
|
||||
$config = new BoundaryConfig(
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 2,
|
||||
maxRetries: 1
|
||||
);
|
||||
|
||||
$boundary = $this->boundaryFactory->create('test_circuit', $config);
|
||||
|
||||
// Trigger failures to open circuit
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$result = $boundary->executeWithCircuitBreaker(
|
||||
operation: fn () => throw new \Exception("Failure {$i}"),
|
||||
fallback: fn () => "Fallback {$i}"
|
||||
);
|
||||
|
||||
$output->writeLine(" Attempt {$i}: {$result}");
|
||||
}
|
||||
|
||||
$output->writeLine(' <info>Circuit should now be open, subsequent calls should use fallback immediately</info>');
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function testBulkOperations(ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<comment>Testing bulk operations...</comment>');
|
||||
|
||||
$boundary = $this->boundaryFactory->create('test_bulk', BoundaryConfig::development());
|
||||
|
||||
$items = range(1, 10);
|
||||
$result = $boundary->executeBulk($items, function ($item) {
|
||||
if ($item % 3 === 0) {
|
||||
throw new \Exception("Item {$item} failed");
|
||||
}
|
||||
|
||||
return $item * 2;
|
||||
});
|
||||
|
||||
$output->writeLine(" Processed: {$result->getProcessedCount()}/{$result->getTotalCount()}");
|
||||
$output->writeLine(" Successful: {$result->getSuccessCount()}");
|
||||
$output->writeLine(" Failed: {$result->getErrorCount()}");
|
||||
$output->writeLine(" Success rate: " . round($result->getSuccessRate(), 1) . "%");
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function testTimeout(ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<comment>Testing timeout functionality...</comment>');
|
||||
|
||||
$boundary = $this->boundaryFactory->create('test_timeout', BoundaryConfig::development());
|
||||
|
||||
// Test normal operation
|
||||
$result = $boundary->executeWithTimeout(
|
||||
operation: fn () => 'quick_operation',
|
||||
fallback: fn () => 'timeout_fallback',
|
||||
timeoutSeconds: 1
|
||||
);
|
||||
|
||||
$quickIcon = $result === 'quick_operation' ? '✅' : '❌';
|
||||
$output->writeLine(" {$quickIcon} Quick operation: {$result}");
|
||||
|
||||
// Note: In real PHP, we can't actually test timeouts without async operations
|
||||
$output->writeLine(' <comment>Note: True timeout testing requires async operations</comment>');
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function showTestOptions(ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<error>Invalid test type. Available options:</error>');
|
||||
$output->writeLine(' <comment>basic</comment> - Test basic success/fallback functionality');
|
||||
$output->writeLine(' <comment>retry</comment> - Test retry strategies');
|
||||
$output->writeLine(' <comment>circuit</comment> - Test circuit breaker');
|
||||
$output->writeLine(' <comment>bulk</comment> - Test bulk operations');
|
||||
$output->writeLine(' <comment>timeout</comment> - Test timeout handling');
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
private function getCircuitBreakerStates(): array
|
||||
{
|
||||
$states = [];
|
||||
$tempDir = sys_get_temp_dir();
|
||||
|
||||
$files = glob($tempDir . '/boundary_*_state');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$boundaryName = str_replace(
|
||||
[$tempDir . '/boundary_', '_state'],
|
||||
'',
|
||||
$file
|
||||
);
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$state = json_decode($content, true);
|
||||
|
||||
if ($state) {
|
||||
$states[$boundaryName] = $state;
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
private function resetCircuitBreaker(string $boundaryName): void
|
||||
{
|
||||
$stateFile = sys_get_temp_dir() . "/boundary_{$boundaryName}_state";
|
||||
|
||||
if (file_exists($stateFile)) {
|
||||
unlink($stateFile);
|
||||
}
|
||||
}
|
||||
|
||||
private function resetAllCircuitBreakers(): void
|
||||
{
|
||||
$files = glob(sys_get_temp_dir() . '/boundary_*_state');
|
||||
|
||||
foreach ($files as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
441
src/Framework/ErrorBoundaries/ErrorBoundary.php
Normal file
441
src/Framework/ErrorBoundaries/ErrorBoundary.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventInterface;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryExecutionFailed;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryExecutionSucceeded;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryFallbackExecuted;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryTimeoutOccurred;
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use App\Framework\Logging\Logger;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Error Boundary for graceful degradation
|
||||
*
|
||||
* Provides a safety net that catches errors and provides fallback functionality
|
||||
* instead of letting the entire system fail.
|
||||
*/
|
||||
final readonly class ErrorBoundary
|
||||
{
|
||||
public function __construct(
|
||||
private string $boundaryName,
|
||||
private BoundaryConfig $config,
|
||||
private Timer $timer,
|
||||
private ?Logger $logger = null,
|
||||
private ?BoundaryCircuitBreakerManager $circuitBreakerManager = null,
|
||||
private ?BoundaryEventPublisher $eventPublisher = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes operation within error boundary with fallback
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param callable(): T $fallback
|
||||
* @return T
|
||||
*/
|
||||
public function execute(callable $operation, callable $fallback): mixed
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
|
||||
try {
|
||||
$result = $this->executeWithRetry($operation);
|
||||
$executionTime = Timestamp::now()->diff($startTime);
|
||||
|
||||
// Publish success event
|
||||
$this->publishEvent(new BoundaryExecutionSucceeded(
|
||||
boundaryName: $this->boundaryName,
|
||||
executionTime: $executionTime,
|
||||
message: 'Operation completed successfully',
|
||||
));
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$executionTime = Timestamp::now()->diff($startTime);
|
||||
|
||||
// Publish failure event
|
||||
$this->publishEvent(new BoundaryExecutionFailed(
|
||||
boundaryName: $this->boundaryName,
|
||||
exception: $e,
|
||||
executionTime: $executionTime,
|
||||
willRetry: false,
|
||||
message: 'Operation failed, executing fallback',
|
||||
));
|
||||
|
||||
return $this->handleFailure($e, $fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes operation with optional result - returns null on failure if no fallback
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param callable(): T|null $fallback
|
||||
* @return T|null
|
||||
*/
|
||||
public function executeOptional(callable $operation, ?callable $fallback = null): mixed
|
||||
{
|
||||
try {
|
||||
return $this->executeWithRetry($operation);
|
||||
} catch (Throwable $e) {
|
||||
if ($fallback !== null) {
|
||||
return $this->handleFailure($e, $fallback);
|
||||
}
|
||||
|
||||
$this->logFailure($e, 'Optional operation failed, returning null');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes operation with default value on failure
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param T $defaultValue
|
||||
* @return T
|
||||
*/
|
||||
public function executeWithDefault(callable $operation, mixed $defaultValue): mixed
|
||||
{
|
||||
try {
|
||||
return $this->executeWithRetry($operation);
|
||||
} catch (Throwable $e) {
|
||||
$this->logFailure($e, 'Operation failed, returning default value');
|
||||
|
||||
return $defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes operation and returns Result object
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @return BoundaryResult<T>
|
||||
*/
|
||||
public function executeForResult(callable $operation): BoundaryResult
|
||||
{
|
||||
try {
|
||||
$result = $this->executeWithRetry($operation);
|
||||
|
||||
return BoundaryResult::success($result);
|
||||
} catch (Throwable $e) {
|
||||
$this->logFailure($e, 'Operation failed, returning error result');
|
||||
|
||||
return BoundaryResult::failure($e, $this->boundaryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes multiple operations in parallel with individual boundaries
|
||||
*
|
||||
* @param array<string, callable> $operations
|
||||
* @return array<string, BoundaryResult>
|
||||
*/
|
||||
public function executeParallel(array $operations): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($operations as $name => $operation) {
|
||||
$results[$name] = $this->executeForResult($operation);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker pattern - prevents repeated calls if failure rate is high
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param callable(): T $fallback
|
||||
* @return T
|
||||
*/
|
||||
public function executeWithCircuitBreaker(callable $operation, callable $fallback): mixed
|
||||
{
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
if ($this->circuitBreakerManager->isCircuitOpen($this->boundaryName, $this->config)) {
|
||||
$this->log('info', 'Circuit breaker is open, using fallback');
|
||||
|
||||
return $fallback();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->executeWithRetry($operation);
|
||||
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
$this->circuitBreakerManager->recordSuccess($this->boundaryName, $this->config);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
$this->circuitBreakerManager->recordFailure($this->boundaryName, $this->config);
|
||||
}
|
||||
|
||||
return $this->handleFailure($e, $fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout boundary - prevents operations from running too long
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @param callable(): T $fallback
|
||||
* @param int $timeoutSeconds
|
||||
* @return T
|
||||
*/
|
||||
public function executeWithTimeout(callable $operation, callable $fallback, int $timeoutSeconds): mixed
|
||||
{
|
||||
$startTime = Timestamp::now();
|
||||
$timeoutThreshold = Duration::fromSeconds($timeoutSeconds);
|
||||
|
||||
try {
|
||||
// PHP doesn't have native async/timeout, so we simulate with time checks
|
||||
$result = $operation();
|
||||
|
||||
$executionTime = Timestamp::now()->diff($startTime);
|
||||
if ($executionTime->greaterThan($timeoutThreshold)) {
|
||||
// Publish timeout event
|
||||
$this->publishEvent(new BoundaryTimeoutOccurred(
|
||||
boundaryName: $this->boundaryName,
|
||||
timeoutThreshold: $timeoutThreshold,
|
||||
actualExecutionTime: $executionTime,
|
||||
fallbackExecuted: true,
|
||||
message: "Operation exceeded timeout of {$timeoutSeconds} seconds",
|
||||
));
|
||||
|
||||
throw new BoundaryTimeoutException(
|
||||
"Operation exceeded timeout of {$timeoutSeconds} seconds",
|
||||
$this->boundaryName,
|
||||
$executionTime->toSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$executionTime = Timestamp::now()->diff($startTime);
|
||||
|
||||
if ($executionTime->greaterThan($timeoutThreshold)) {
|
||||
$this->log('warning', "Operation timed out after {$executionTime->toHumanReadable()}");
|
||||
|
||||
// Publish timeout event if not already published
|
||||
if (! ($e instanceof BoundaryTimeoutException)) {
|
||||
$this->publishEvent(new BoundaryTimeoutOccurred(
|
||||
boundaryName: $this->boundaryName,
|
||||
timeoutThreshold: $timeoutThreshold,
|
||||
actualExecutionTime: $executionTime,
|
||||
fallbackExecuted: true,
|
||||
message: "Operation timed out during execution",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->handleFailure($e, $fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk operation boundary - processes items individually with partial failure tolerance
|
||||
*
|
||||
* @template T
|
||||
* @param array<T> $items
|
||||
* @param callable(T): mixed $processor
|
||||
* @return BulkResult<T>
|
||||
*/
|
||||
public function executeBulk(array $items, callable $processor): BulkResult
|
||||
{
|
||||
$results = [];
|
||||
$errors = [];
|
||||
$processed = 0;
|
||||
|
||||
foreach ($items as $key => $item) {
|
||||
try {
|
||||
$results[$key] = $processor($item);
|
||||
$processed++;
|
||||
} catch (Throwable $e) {
|
||||
$errors[$key] = $e;
|
||||
$this->logFailure($e, "Bulk processing failed for item {$key}");
|
||||
|
||||
// Stop processing if error rate is too high
|
||||
if (count($errors) / (count($results) + count($errors)) > $this->config->maxBulkErrorRate) {
|
||||
$this->log('error', 'Bulk processing stopped due to high error rate');
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BulkResult($results, $errors, $processed, count($items));
|
||||
}
|
||||
|
||||
private function executeWithRetry(callable $operation): mixed
|
||||
{
|
||||
$lastException = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $this->config->maxRetries; $attempt++) {
|
||||
try {
|
||||
return $operation();
|
||||
} catch (Throwable $e) {
|
||||
$lastException = $e;
|
||||
|
||||
// Don't retry certain types of errors
|
||||
if (! $this->shouldRetry($e)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($attempt < $this->config->maxRetries) {
|
||||
$delay = $this->calculateRetryDelay($attempt);
|
||||
$this->log('info', "Attempt {$attempt} failed, retrying in {$delay}ms", [
|
||||
'exception' => $e->getMessage(),
|
||||
'attempt' => $attempt,
|
||||
'delay' => $delay,
|
||||
]);
|
||||
|
||||
$this->timer->sleep(Duration::fromMilliseconds($delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
private function handleFailure(Throwable $exception, callable $fallback): mixed
|
||||
{
|
||||
$this->logFailure($exception, 'Operation failed, executing fallback');
|
||||
|
||||
try {
|
||||
$result = $fallback();
|
||||
|
||||
// Publish fallback executed event
|
||||
$this->publishEvent(new BoundaryFallbackExecuted(
|
||||
boundaryName: $this->boundaryName,
|
||||
originalException: $exception,
|
||||
fallbackReason: 'Operation failed: ' . $exception->getMessage(),
|
||||
message: 'Fallback executed successfully',
|
||||
));
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $fallbackException) {
|
||||
$this->log('error', 'Fallback also failed', [
|
||||
'original_exception' => $exception->getMessage(),
|
||||
'fallback_exception' => $fallbackException->getMessage(),
|
||||
]);
|
||||
|
||||
// Create a boundary exception that includes both errors
|
||||
throw new BoundaryFailedException(
|
||||
"Both operation and fallback failed in boundary '{$this->boundaryName}'",
|
||||
$this->boundaryName,
|
||||
$exception,
|
||||
$fallbackException
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldRetry(Throwable $e): bool
|
||||
{
|
||||
// Don't retry validation errors, security errors, etc.
|
||||
if ($e instanceof FrameworkException) {
|
||||
$nonRetryableCodes = [
|
||||
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
|
||||
ErrorCode::VAL_INVALID_FORMAT,
|
||||
ErrorCode::SEC_XSS_ATTEMPT,
|
||||
ErrorCode::SEC_SQL_INJECTION_ATTEMPT,
|
||||
ErrorCode::AUTH_CREDENTIALS_INVALID,
|
||||
ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES,
|
||||
];
|
||||
|
||||
if (in_array($e->getErrorCode(), $nonRetryableCodes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function calculateRetryDelay(int $attempt): int
|
||||
{
|
||||
$baseMs = (int) $this->config->baseDelay->toMilliseconds();
|
||||
$maxMs = (int) $this->config->maxDelay->toMilliseconds();
|
||||
|
||||
return match ($this->config->retryStrategy) {
|
||||
RetryStrategy::FIXED => $baseMs,
|
||||
RetryStrategy::LINEAR => min($baseMs * $attempt, $maxMs),
|
||||
RetryStrategy::EXPONENTIAL => min(
|
||||
$baseMs * (2 ** ($attempt - 1)),
|
||||
$maxMs
|
||||
),
|
||||
RetryStrategy::EXPONENTIAL_JITTER => min(
|
||||
$baseMs * (2 ** ($attempt - 1)) + random_int(0, 100),
|
||||
$maxMs
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker health information
|
||||
*/
|
||||
public function getCircuitHealth(): ?array
|
||||
{
|
||||
if (! $this->config->circuitBreakerEnabled || ! $this->circuitBreakerManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->circuitBreakerManager->getCircuitHealth($this->boundaryName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset circuit breaker state
|
||||
*/
|
||||
public function resetCircuit(): void
|
||||
{
|
||||
if ($this->config->circuitBreakerEnabled && $this->circuitBreakerManager) {
|
||||
$this->circuitBreakerManager->resetCircuit($this->boundaryName);
|
||||
}
|
||||
}
|
||||
|
||||
private function logFailure(Throwable $e, string $message): void
|
||||
{
|
||||
$this->log('error', $message, [
|
||||
'exception' => $e::class,
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger) {
|
||||
$context['boundary'] = $this->boundaryName;
|
||||
|
||||
match ($level) {
|
||||
'debug' => $this->logger->debug("[ErrorBoundary] {$message}", $context),
|
||||
'info' => $this->logger->info("[ErrorBoundary] {$message}", $context),
|
||||
'warning' => $this->logger->warning("[ErrorBoundary] {$message}", $context),
|
||||
'error' => $this->logger->error("[ErrorBoundary] {$message}", $context),
|
||||
default => $this->logger->info("[ErrorBoundary] {$message}", $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private function publishEvent(BoundaryEventInterface $event): void
|
||||
{
|
||||
$this->eventPublisher?->publish($event);
|
||||
}
|
||||
}
|
||||
176
src/Framework/ErrorBoundaries/ErrorBoundaryFactory.php
Normal file
176
src/Framework/ErrorBoundaries/ErrorBoundaryFactory.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\SystemTimer;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitBreakerManager;
|
||||
use App\Framework\ErrorBoundaries\Events\BoundaryEventPublisher;
|
||||
use App\Framework\EventBus\EventBus;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\StateManagement\StateManagerFactory;
|
||||
|
||||
/**
|
||||
* Factory for creating error boundaries with appropriate configurations
|
||||
*/
|
||||
final readonly class ErrorBoundaryFactory
|
||||
{
|
||||
private array $routeConfigs;
|
||||
|
||||
public function __construct(
|
||||
private ?Timer $timer = null,
|
||||
private ?Logger $logger = null,
|
||||
private ?StateManagerFactory $stateManagerFactory = null,
|
||||
private ?EventBus $eventBus = null,
|
||||
array $routeConfigs = []
|
||||
) {
|
||||
$this->routeConfigs = array_merge($this->getDefaultRouteConfigs(), $routeConfigs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error boundary for specific route
|
||||
*/
|
||||
public function createForRoute(string $routeName): ErrorBoundary
|
||||
{
|
||||
$config = $this->getConfigForRoute($routeName);
|
||||
|
||||
return $this->create("route_{$routeName}", $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error boundary for database operations
|
||||
*/
|
||||
public function createForDatabase(string $operation = 'database'): ErrorBoundary
|
||||
{
|
||||
return $this->create($operation, BoundaryConfig::database());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error boundary for external service calls
|
||||
*/
|
||||
public function createForExternalService(string $serviceName): ErrorBoundary
|
||||
{
|
||||
return $this->create("external_{$serviceName}", BoundaryConfig::externalService());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error boundary for UI components
|
||||
*/
|
||||
public function createForUI(string $componentName): ErrorBoundary
|
||||
{
|
||||
return $this->create("ui_{$componentName}", BoundaryConfig::ui());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error boundary for background jobs
|
||||
*/
|
||||
public function createForBackgroundJob(string $jobName): ErrorBoundary
|
||||
{
|
||||
return $this->create("job_{$jobName}", BoundaryConfig::backgroundJob());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error boundary for critical operations
|
||||
*/
|
||||
public function createForCriticalOperation(string $operationName): ErrorBoundary
|
||||
{
|
||||
return $this->create("critical_{$operationName}", BoundaryConfig::critical());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom error boundary
|
||||
*/
|
||||
public function create(string $name, BoundaryConfig $config): ErrorBoundary
|
||||
{
|
||||
$circuitBreakerManager = null;
|
||||
|
||||
if ($config->circuitBreakerEnabled && $this->stateManagerFactory) {
|
||||
$stateManager = $this->stateManagerFactory->createForErrorBoundary();
|
||||
$circuitBreakerManager = new BoundaryCircuitBreakerManager($stateManager, $this->logger);
|
||||
}
|
||||
|
||||
$eventPublisher = new BoundaryEventPublisher($this->eventBus, $this->logger);
|
||||
|
||||
return new ErrorBoundary(
|
||||
boundaryName: $name,
|
||||
config: $config,
|
||||
timer: $this->timer ?? new SystemTimer(),
|
||||
logger: $this->logger,
|
||||
circuitBreakerManager: $circuitBreakerManager,
|
||||
eventPublisher: $eventPublisher,
|
||||
);
|
||||
}
|
||||
|
||||
private function getConfigForRoute(string $routeName): BoundaryConfig
|
||||
{
|
||||
// Check for exact route match
|
||||
if (isset($this->routeConfigs[$routeName])) {
|
||||
return $this->routeConfigs[$routeName];
|
||||
}
|
||||
|
||||
// Check for pattern matches
|
||||
foreach ($this->routeConfigs as $pattern => $config) {
|
||||
if (str_contains($pattern, '*') && $this->matchesPattern($routeName, $pattern)) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
return $this->routeConfigs['default'];
|
||||
}
|
||||
|
||||
private function matchesPattern(string $routeName, string $pattern): bool
|
||||
{
|
||||
$regex = str_replace('*', '.*', preg_quote($pattern, '/'));
|
||||
|
||||
return (bool) preg_match("/^{$regex}$/", $routeName);
|
||||
}
|
||||
|
||||
private function getDefaultRouteConfigs(): array
|
||||
{
|
||||
return [
|
||||
// API routes - more retries and circuit breaker
|
||||
'api/*' => BoundaryConfig::externalService(),
|
||||
|
||||
// Admin routes - critical operations
|
||||
'admin/*' => BoundaryConfig::critical(),
|
||||
|
||||
// Auth routes - fail fast for security
|
||||
'auth/*' => BoundaryConfig::failFast(),
|
||||
|
||||
// Public routes - user-friendly defaults
|
||||
'public/*' => BoundaryConfig::ui(),
|
||||
|
||||
// Background job routes
|
||||
'job/*' => BoundaryConfig::backgroundJob(),
|
||||
|
||||
// Database operations
|
||||
'db/*' => BoundaryConfig::database(),
|
||||
|
||||
// Default fallback
|
||||
'default' => new BoundaryConfig(
|
||||
maxRetries: 2,
|
||||
retryStrategy: RetryStrategy::EXPONENTIAL_JITTER,
|
||||
baseDelay: Duration::fromMilliseconds(100),
|
||||
maxDelay: Duration::fromSeconds(2),
|
||||
circuitBreakerEnabled: true,
|
||||
circuitBreakerThreshold: 5,
|
||||
circuitBreakerTimeout: Duration::fromMinutes(1),
|
||||
enableMetrics: true
|
||||
),
|
||||
|
||||
// HTTP request fallback
|
||||
'http_request' => new BoundaryConfig(
|
||||
maxRetries: 1,
|
||||
retryStrategy: RetryStrategy::FIXED,
|
||||
baseDelay: Duration::fromMilliseconds(50),
|
||||
maxDelay: Duration::fromMilliseconds(200),
|
||||
circuitBreakerEnabled: false,
|
||||
enableMetrics: true
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
106
src/Framework/ErrorBoundaries/ErrorBoundaryInitializer.php
Normal file
106
src/Framework/ErrorBoundaries/ErrorBoundaryInitializer.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Initializer for Error Boundaries
|
||||
*/
|
||||
final readonly class ErrorBoundaryInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initialize(Container $container): void
|
||||
{
|
||||
// Factory
|
||||
$container->bind(ErrorBoundaryFactory::class, function (Container $container) {
|
||||
$logger = $container->has(Logger::class) ? $container->get(Logger::class) : null;
|
||||
|
||||
// Load custom route configurations from environment
|
||||
$routeConfigs = $this->loadRouteConfigs();
|
||||
|
||||
return new ErrorBoundaryFactory(
|
||||
logger: $logger,
|
||||
routeConfigs: $routeConfigs
|
||||
);
|
||||
});
|
||||
|
||||
// Default boundary for general use
|
||||
$container->bind(ErrorBoundary::class, function (Container $container) {
|
||||
$factory = $container->get(ErrorBoundaryFactory::class);
|
||||
|
||||
return $factory->create('default', BoundaryConfig::database());
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private function loadRouteConfigs(): array
|
||||
{
|
||||
$configs = [];
|
||||
|
||||
// Load from environment variables
|
||||
// Format: ERROR_BOUNDARY_ROUTE_[ROUTE_NAME]_[SETTING] = value
|
||||
foreach ($_ENV as $key => $value) {
|
||||
if (! str_starts_with($key, 'ERROR_BOUNDARY_ROUTE_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('_', str_replace('ERROR_BOUNDARY_ROUTE_', '', $key));
|
||||
if (count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$setting = array_pop($parts);
|
||||
$routeName = strtolower(implode('_', $parts));
|
||||
|
||||
if (! isset($configs[$routeName])) {
|
||||
$configs[$routeName] = [];
|
||||
}
|
||||
|
||||
$configs[$routeName][$setting] = $this->parseConfigValue($setting, $value);
|
||||
}
|
||||
|
||||
// Convert to BoundaryConfig objects
|
||||
$boundaryConfigs = [];
|
||||
foreach ($configs as $routeName => $settings) {
|
||||
$boundaryConfigs[$routeName] = $this->createConfigFromArray($settings);
|
||||
}
|
||||
|
||||
return $boundaryConfigs;
|
||||
}
|
||||
|
||||
private function parseConfigValue(string $setting, string $value): mixed
|
||||
{
|
||||
return match (strtolower($setting)) {
|
||||
'max_retries', 'circuit_breaker_threshold' => (int) $value,
|
||||
'circuit_breaker_enabled', 'enable_metrics', 'enable_tracing' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
|
||||
'max_bulk_error_rate' => (float) $value,
|
||||
'retry_strategy' => RetryStrategy::tryFrom(strtolower($value)) ?? RetryStrategy::EXPONENTIAL_JITTER,
|
||||
'base_delay_ms' => Duration::fromMilliseconds((int) $value),
|
||||
'max_delay_ms' => Duration::fromMilliseconds((int) $value),
|
||||
'circuit_breaker_timeout_s' => Duration::fromSeconds((int) $value),
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function createConfigFromArray(array $settings): BoundaryConfig
|
||||
{
|
||||
return new BoundaryConfig(
|
||||
maxRetries: $settings['max_retries'] ?? 3,
|
||||
retryStrategy: $settings['retry_strategy'] ?? RetryStrategy::EXPONENTIAL_JITTER,
|
||||
baseDelay: $settings['base_delay_ms'] ?? Duration::fromMilliseconds(100),
|
||||
maxDelay: $settings['max_delay_ms'] ?? Duration::fromSeconds(5),
|
||||
circuitBreakerEnabled: $settings['circuit_breaker_enabled'] ?? false,
|
||||
circuitBreakerThreshold: $settings['circuit_breaker_threshold'] ?? 5,
|
||||
circuitBreakerTimeout: $settings['circuit_breaker_timeout_s'] ?? Duration::fromMinutes(1),
|
||||
maxBulkErrorRate: $settings['max_bulk_error_rate'] ?? 0.5,
|
||||
enableMetrics: $settings['enable_metrics'] ?? true,
|
||||
enableTracing: $settings['enable_tracing'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitState;
|
||||
|
||||
/**
|
||||
* Event fired when circuit breaker recovers (closes)
|
||||
*/
|
||||
final readonly class BoundaryCircuitBreakerRecovered implements BoundaryEventInterface
|
||||
{
|
||||
public readonly string $eventType;
|
||||
|
||||
public readonly Timestamp $occurredAt;
|
||||
|
||||
public readonly string $severity;
|
||||
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public BoundaryCircuitState $previousState,
|
||||
public BoundaryCircuitState $newState,
|
||||
public Duration $downTime,
|
||||
public int $successCount,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->eventType = 'boundary.circuit_breaker.recovered';
|
||||
$this->occurredAt = Timestamp::now();
|
||||
$this->severity = 'info';
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return "boundary_circuit_recovery_{$this->boundaryName}_{$this->occurredAt->toFloat()}";
|
||||
}
|
||||
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return false; // Recovery is good news, typically no alert needed
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->getEventId(),
|
||||
'event_type' => $this->eventType,
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'previous_state' => $this->previousState->value,
|
||||
'new_state' => $this->newState->value,
|
||||
'down_time_ms' => $this->downTime->toMilliseconds(),
|
||||
'down_time_human' => $this->downTime->toHumanReadable(),
|
||||
'success_count' => $this->successCount,
|
||||
'state_description' => $this->newState->getDescription(),
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\ErrorBoundaries\CircuitBreaker\BoundaryCircuitState;
|
||||
|
||||
/**
|
||||
* Event fired when circuit breaker trips (opens)
|
||||
*/
|
||||
final readonly class BoundaryCircuitBreakerTripped implements BoundaryEventInterface
|
||||
{
|
||||
public readonly string $eventType;
|
||||
|
||||
public readonly Timestamp $occurredAt;
|
||||
|
||||
public readonly string $severity;
|
||||
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public BoundaryCircuitState $previousState,
|
||||
public BoundaryCircuitState $newState,
|
||||
public int $failureCount,
|
||||
public int $failureThreshold,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->eventType = 'boundary.circuit_breaker.tripped';
|
||||
$this->occurredAt = Timestamp::now();
|
||||
$this->severity = 'error';
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return "boundary_circuit_trip_{$this->boundaryName}_{$this->occurredAt->toFloat()}";
|
||||
}
|
||||
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return true; // Circuit breaker trips always require attention
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->getEventId(),
|
||||
'event_type' => $this->eventType,
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'previous_state' => $this->previousState->value,
|
||||
'new_state' => $this->newState->value,
|
||||
'failure_count' => $this->failureCount,
|
||||
'failure_threshold' => $this->failureThreshold,
|
||||
'state_description' => $this->newState->getDescription(),
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Base interface for error boundary events
|
||||
*/
|
||||
interface BoundaryEventInterface
|
||||
{
|
||||
public string $boundaryName {get;}
|
||||
|
||||
public string $eventType {get;}
|
||||
|
||||
public Timestamp $occurredAt {get;}
|
||||
|
||||
public string $severity {get;}
|
||||
|
||||
public ?string $message {get;}
|
||||
|
||||
public array $context {get;}
|
||||
|
||||
/**
|
||||
* Get event identifier for logging/tracking
|
||||
*/
|
||||
public function getEventId(): string;
|
||||
|
||||
/**
|
||||
* Check if event should trigger alerts
|
||||
*/
|
||||
public function shouldAlert(): bool;
|
||||
|
||||
/**
|
||||
* Get event payload for serialization
|
||||
*/
|
||||
public function toArray(): array;
|
||||
}
|
||||
125
src/Framework/ErrorBoundaries/Events/BoundaryEventPublisher.php
Normal file
125
src/Framework/ErrorBoundaries/Events/BoundaryEventPublisher.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\EventBus\EventBus;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Publisher for error boundary events
|
||||
*/
|
||||
final readonly class BoundaryEventPublisher
|
||||
{
|
||||
public function __construct(
|
||||
private ?EventBus $eventBus = null,
|
||||
private ?Logger $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish boundary event
|
||||
*/
|
||||
public function publish(BoundaryEventInterface $event): void
|
||||
{
|
||||
// Log the event
|
||||
$this->logEvent($event);
|
||||
|
||||
// Publish to event bus if available
|
||||
if ($this->eventBus !== null) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish execution success event
|
||||
*/
|
||||
public function publishExecutionSucceeded(BoundaryExecutionSucceeded $event): void
|
||||
{
|
||||
$this->publish($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish execution failure event
|
||||
*/
|
||||
public function publishExecutionFailed(BoundaryExecutionFailed $event): void
|
||||
{
|
||||
$this->publish($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish fallback execution event
|
||||
*/
|
||||
public function publishFallbackExecuted(BoundaryFallbackExecuted $event): void
|
||||
{
|
||||
$this->publish($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish circuit breaker tripped event
|
||||
*/
|
||||
public function publishCircuitBreakerTripped(BoundaryCircuitBreakerTripped $event): void
|
||||
{
|
||||
$this->publish($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish circuit breaker recovered event
|
||||
*/
|
||||
public function publishCircuitBreakerRecovered(BoundaryCircuitBreakerRecovered $event): void
|
||||
{
|
||||
$this->publish($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish timeout event
|
||||
*/
|
||||
public function publishTimeoutOccurred(BoundaryTimeoutOccurred $event): void
|
||||
{
|
||||
$this->publish($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch publish multiple events
|
||||
*/
|
||||
public function publishBatch(array $events): void
|
||||
{
|
||||
foreach ($events as $event) {
|
||||
if ($event instanceof BoundaryEventInterface) {
|
||||
$this->publish($event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function logEvent(BoundaryEventInterface $event): void
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = [
|
||||
'event_id' => $event->getEventId(),
|
||||
'boundary_name' => $event->boundaryName,
|
||||
'event_type' => $event->eventType,
|
||||
'occurred_at' => $event->occurredAt->toIsoString(),
|
||||
'should_alert' => $event->shouldAlert(),
|
||||
'event_data' => $event->toArray(),
|
||||
];
|
||||
|
||||
$logMessage = "[ErrorBoundary Event] {$event->eventType} for boundary '{$event->boundaryName}'";
|
||||
|
||||
if ($event->message !== null) {
|
||||
$logMessage .= ": {$event->message}";
|
||||
}
|
||||
|
||||
match ($event->severity) {
|
||||
'debug' => $this->logger->debug($logMessage, $context),
|
||||
'info' => $this->logger->info($logMessage, $context),
|
||||
'warning' => $this->logger->warning($logMessage, $context),
|
||||
'error' => $this->logger->error($logMessage, $context),
|
||||
'critical' => $this->logger->error($logMessage, $context), // Map critical to error
|
||||
default => $this->logger->info($logMessage, $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Event fired when boundary execution fails
|
||||
*/
|
||||
final readonly class BoundaryExecutionFailed implements BoundaryEventInterface
|
||||
{
|
||||
public readonly string $eventType;
|
||||
|
||||
public readonly Timestamp $occurredAt;
|
||||
|
||||
public readonly string $severity;
|
||||
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public Throwable $exception,
|
||||
public Duration $executionTime,
|
||||
public bool $willRetry = false,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->eventType = 'boundary.execution.failed';
|
||||
$this->occurredAt = Timestamp::now();
|
||||
$this->severity = $willRetry ? 'warning' : 'error';
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return "boundary_failure_{$this->boundaryName}_{$this->occurredAt->toFloat()}";
|
||||
}
|
||||
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return ! $this->willRetry; // Alert on final failures, not retries
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->getEventId(),
|
||||
'event_type' => $this->eventType,
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'exception_class' => $this->exception::class,
|
||||
'exception_message' => $this->exception->getMessage(),
|
||||
'exception_file' => $this->exception->getFile(),
|
||||
'exception_line' => $this->exception->getLine(),
|
||||
'execution_time_ms' => $this->executionTime->toMilliseconds(),
|
||||
'execution_time_human' => $this->executionTime->toHumanReadable(),
|
||||
'will_retry' => $this->willRetry,
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Event fired when boundary execution succeeds
|
||||
*/
|
||||
final readonly class BoundaryExecutionSucceeded implements BoundaryEventInterface
|
||||
{
|
||||
public readonly string $eventType;
|
||||
|
||||
public readonly Timestamp $occurredAt;
|
||||
|
||||
public readonly string $severity;
|
||||
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public Duration $executionTime,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->eventType = 'boundary.execution.succeeded';
|
||||
$this->occurredAt = Timestamp::now();
|
||||
$this->severity = 'info';
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return "boundary_success_{$this->boundaryName}_{$this->occurredAt->toFloat()}";
|
||||
}
|
||||
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return false; // Success events typically don't trigger alerts
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->getEventId(),
|
||||
'event_type' => $this->eventType,
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'execution_time_ms' => $this->executionTime->toMilliseconds(),
|
||||
'execution_time_human' => $this->executionTime->toHumanReadable(),
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Event fired when boundary fallback is executed
|
||||
*/
|
||||
final readonly class BoundaryFallbackExecuted implements BoundaryEventInterface
|
||||
{
|
||||
public readonly string $eventType;
|
||||
|
||||
public readonly Timestamp $occurredAt;
|
||||
|
||||
public readonly string $severity;
|
||||
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public Throwable $originalException,
|
||||
public string $fallbackReason,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->eventType = 'boundary.fallback.executed';
|
||||
$this->occurredAt = Timestamp::now();
|
||||
$this->severity = 'warning';
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return "boundary_fallback_{$this->boundaryName}_{$this->occurredAt->toFloat()}";
|
||||
}
|
||||
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return true; // Fallback executions indicate issues
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->getEventId(),
|
||||
'event_type' => $this->eventType,
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'original_exception_class' => $this->originalException::class,
|
||||
'original_exception_message' => $this->originalException->getMessage(),
|
||||
'fallback_reason' => $this->fallbackReason,
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Event fired when boundary operation times out
|
||||
*/
|
||||
final readonly class BoundaryTimeoutOccurred implements BoundaryEventInterface
|
||||
{
|
||||
public readonly string $eventType;
|
||||
|
||||
public readonly Timestamp $occurredAt;
|
||||
|
||||
public readonly string $severity;
|
||||
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public Duration $timeoutThreshold,
|
||||
public Duration $actualExecutionTime,
|
||||
public bool $fallbackExecuted = false,
|
||||
public ?string $message = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->eventType = 'boundary.timeout.occurred';
|
||||
$this->occurredAt = Timestamp::now();
|
||||
$this->severity = 'warning';
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return "boundary_timeout_{$this->boundaryName}_{$this->occurredAt->toFloat()}";
|
||||
}
|
||||
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return true; // Timeouts indicate performance issues
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => $this->getEventId(),
|
||||
'event_type' => $this->eventType,
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'timeout_threshold_ms' => $this->timeoutThreshold->toMilliseconds(),
|
||||
'timeout_threshold_human' => $this->timeoutThreshold->toHumanReadable(),
|
||||
'actual_execution_time_ms' => $this->actualExecutionTime->toMilliseconds(),
|
||||
'actual_execution_time_human' => $this->actualExecutionTime->toHumanReadable(),
|
||||
'exceeded_by_ms' => $this->actualExecutionTime->diff($this->timeoutThreshold)->toMilliseconds(),
|
||||
'fallback_executed' => $this->fallbackExecuted,
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'should_alert' => $this->shouldAlert(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Metrics;
|
||||
|
||||
/**
|
||||
* Health status enumeration for error boundaries
|
||||
*/
|
||||
enum BoundaryHealthStatus: string
|
||||
{
|
||||
case HEALTHY = 'healthy';
|
||||
case WARNING = 'warning';
|
||||
case CRITICAL = 'critical';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY => 'Boundary is operating normally',
|
||||
self::WARNING => 'Boundary has elevated error rates but is functional',
|
||||
self::CRITICAL => 'Boundary has high error rates and may be failing',
|
||||
self::UNKNOWN => 'Boundary health status cannot be determined',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level for alerting
|
||||
*/
|
||||
public function getSeverity(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY => 0,
|
||||
self::WARNING => 1,
|
||||
self::CRITICAL => 2,
|
||||
self::UNKNOWN => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color representation for UI
|
||||
*/
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY => 'green',
|
||||
self::WARNING => 'yellow',
|
||||
self::CRITICAL => 'red',
|
||||
self::UNKNOWN => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji representation
|
||||
*/
|
||||
public function getEmoji(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HEALTHY => '✅',
|
||||
self::WARNING => '⚠️',
|
||||
self::CRITICAL => '🚨',
|
||||
self::UNKNOWN => '❓',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status requires immediate attention
|
||||
*/
|
||||
public function requiresAttention(): bool
|
||||
{
|
||||
return $this === self::CRITICAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status should trigger alerts
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
return $this === self::WARNING || $this === self::CRITICAL;
|
||||
}
|
||||
}
|
||||
308
src/Framework/ErrorBoundaries/Metrics/BoundaryMetrics.php
Normal file
308
src/Framework/ErrorBoundaries/Metrics/BoundaryMetrics.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Metrics;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Metrics for error boundary operations
|
||||
*/
|
||||
final readonly class BoundaryMetrics
|
||||
{
|
||||
public function __construct(
|
||||
public string $boundaryName,
|
||||
public int $totalExecutions = 0,
|
||||
public int $successfulExecutions = 0,
|
||||
public int $failedExecutions = 0,
|
||||
public int $fallbackExecutions = 0,
|
||||
public int $timeoutExecutions = 0,
|
||||
public int $circuitBreakerTrips = 0,
|
||||
public Duration $totalExecutionTime = new Duration(0.0),
|
||||
public Duration $averageExecutionTime = new Duration(0.0),
|
||||
public Duration $maxExecutionTime = new Duration(0.0),
|
||||
public Duration $minExecutionTime = new Duration(0.0),
|
||||
public ?Timestamp $lastSuccessTime = null,
|
||||
public ?Timestamp $lastFailureTime = null,
|
||||
public ?Timestamp $lastFallbackTime = null,
|
||||
public ?Timestamp $createdAt = null,
|
||||
public ?Timestamp $updatedAt = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate success rate
|
||||
*/
|
||||
public function getSuccessRate(): Percentage
|
||||
{
|
||||
if ($this->totalExecutions === 0) {
|
||||
return Percentage::fromFloat(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromFloat($this->successfulExecutions / $this->totalExecutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate failure rate
|
||||
*/
|
||||
public function getFailureRate(): Percentage
|
||||
{
|
||||
if ($this->totalExecutions === 0) {
|
||||
return Percentage::fromFloat(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromFloat($this->failedExecutions / $this->totalExecutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fallback rate
|
||||
*/
|
||||
public function getFallbackRate(): Percentage
|
||||
{
|
||||
if ($this->totalExecutions === 0) {
|
||||
return Percentage::fromFloat(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromFloat($this->fallbackExecutions / $this->totalExecutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate timeout rate
|
||||
*/
|
||||
public function getTimeoutRate(): Percentage
|
||||
{
|
||||
if ($this->totalExecutions === 0) {
|
||||
return Percentage::fromFloat(0.0);
|
||||
}
|
||||
|
||||
return Percentage::fromFloat($this->timeoutExecutions / $this->totalExecutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if boundary is healthy (success rate > 90%)
|
||||
*/
|
||||
public function isHealthy(): bool
|
||||
{
|
||||
return $this->getSuccessRate()->toFloat() > 0.9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit breaker is frequently tripping
|
||||
*/
|
||||
public function hasFrequentCircuitBreakerTrips(): bool
|
||||
{
|
||||
if ($this->totalExecutions === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tripRate = $this->circuitBreakerTrips / $this->totalExecutions;
|
||||
|
||||
return $tripRate > 0.1; // More than 10% of executions result in circuit breaker trips
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status
|
||||
*/
|
||||
public function getHealthStatus(): BoundaryHealthStatus
|
||||
{
|
||||
if ($this->isHealthy() && ! $this->hasFrequentCircuitBreakerTrips()) {
|
||||
return BoundaryHealthStatus::HEALTHY;
|
||||
}
|
||||
|
||||
if ($this->getSuccessRate()->toFloat() > 0.7) {
|
||||
return BoundaryHealthStatus::WARNING;
|
||||
}
|
||||
|
||||
return BoundaryHealthStatus::CRITICAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record successful execution
|
||||
*/
|
||||
public function recordSuccess(Duration $executionTime): self
|
||||
{
|
||||
$newTotalTime = $this->totalExecutionTime->add($executionTime);
|
||||
$newTotalExecutions = $this->totalExecutions + 1;
|
||||
$newAverageTime = Duration::fromMilliseconds(
|
||||
$newTotalTime->toMilliseconds() / $newTotalExecutions
|
||||
);
|
||||
|
||||
return new self(
|
||||
boundaryName: $this->boundaryName,
|
||||
totalExecutions: $newTotalExecutions,
|
||||
successfulExecutions: $this->successfulExecutions + 1,
|
||||
failedExecutions: $this->failedExecutions,
|
||||
fallbackExecutions: $this->fallbackExecutions,
|
||||
timeoutExecutions: $this->timeoutExecutions,
|
||||
circuitBreakerTrips: $this->circuitBreakerTrips,
|
||||
totalExecutionTime: $newTotalTime,
|
||||
averageExecutionTime: $newAverageTime,
|
||||
maxExecutionTime: $executionTime->isGreaterThan($this->maxExecutionTime)
|
||||
? $executionTime
|
||||
: $this->maxExecutionTime,
|
||||
minExecutionTime: $this->minExecutionTime->isZero() || $executionTime->isLessThan($this->minExecutionTime)
|
||||
? $executionTime
|
||||
: $this->minExecutionTime,
|
||||
lastSuccessTime: Timestamp::now(),
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
lastFallbackTime: $this->lastFallbackTime,
|
||||
createdAt: $this->createdAt ?? Timestamp::now(),
|
||||
updatedAt: Timestamp::now(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record failed execution
|
||||
*/
|
||||
public function recordFailure(Duration $executionTime): self
|
||||
{
|
||||
$newTotalTime = $this->totalExecutionTime->add($executionTime);
|
||||
$newTotalExecutions = $this->totalExecutions + 1;
|
||||
$newAverageTime = Duration::fromMilliseconds(
|
||||
$newTotalTime->toMilliseconds() / $newTotalExecutions
|
||||
);
|
||||
|
||||
return new self(
|
||||
boundaryName: $this->boundaryName,
|
||||
totalExecutions: $newTotalExecutions,
|
||||
successfulExecutions: $this->successfulExecutions,
|
||||
failedExecutions: $this->failedExecutions + 1,
|
||||
fallbackExecutions: $this->fallbackExecutions,
|
||||
timeoutExecutions: $this->timeoutExecutions,
|
||||
circuitBreakerTrips: $this->circuitBreakerTrips,
|
||||
totalExecutionTime: $newTotalTime,
|
||||
averageExecutionTime: $newAverageTime,
|
||||
maxExecutionTime: $executionTime->isGreaterThan($this->maxExecutionTime)
|
||||
? $executionTime
|
||||
: $this->maxExecutionTime,
|
||||
minExecutionTime: $this->minExecutionTime->isZero() || $executionTime->isLessThan($this->minExecutionTime)
|
||||
? $executionTime
|
||||
: $this->minExecutionTime,
|
||||
lastSuccessTime: $this->lastSuccessTime,
|
||||
lastFailureTime: Timestamp::now(),
|
||||
lastFallbackTime: $this->lastFallbackTime,
|
||||
createdAt: $this->createdAt ?? Timestamp::now(),
|
||||
updatedAt: Timestamp::now(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record fallback execution
|
||||
*/
|
||||
public function recordFallback(): self
|
||||
{
|
||||
return new self(
|
||||
boundaryName: $this->boundaryName,
|
||||
totalExecutions: $this->totalExecutions + 1,
|
||||
successfulExecutions: $this->successfulExecutions,
|
||||
failedExecutions: $this->failedExecutions,
|
||||
fallbackExecutions: $this->fallbackExecutions + 1,
|
||||
timeoutExecutions: $this->timeoutExecutions,
|
||||
circuitBreakerTrips: $this->circuitBreakerTrips,
|
||||
totalExecutionTime: $this->totalExecutionTime,
|
||||
averageExecutionTime: $this->averageExecutionTime,
|
||||
maxExecutionTime: $this->maxExecutionTime,
|
||||
minExecutionTime: $this->minExecutionTime,
|
||||
lastSuccessTime: $this->lastSuccessTime,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
lastFallbackTime: Timestamp::now(),
|
||||
createdAt: $this->createdAt ?? Timestamp::now(),
|
||||
updatedAt: Timestamp::now(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record timeout execution
|
||||
*/
|
||||
public function recordTimeout(Duration $executionTime): self
|
||||
{
|
||||
$newTotalTime = $this->totalExecutionTime->add($executionTime);
|
||||
$newTotalExecutions = $this->totalExecutions + 1;
|
||||
$newAverageTime = Duration::fromMilliseconds(
|
||||
$newTotalTime->toMilliseconds() / $newTotalExecutions
|
||||
);
|
||||
|
||||
return new self(
|
||||
boundaryName: $this->boundaryName,
|
||||
totalExecutions: $newTotalExecutions,
|
||||
successfulExecutions: $this->successfulExecutions,
|
||||
failedExecutions: $this->failedExecutions,
|
||||
fallbackExecutions: $this->fallbackExecutions,
|
||||
timeoutExecutions: $this->timeoutExecutions + 1,
|
||||
circuitBreakerTrips: $this->circuitBreakerTrips,
|
||||
totalExecutionTime: $newTotalTime,
|
||||
averageExecutionTime: $newAverageTime,
|
||||
maxExecutionTime: $executionTime->isGreaterThan($this->maxExecutionTime)
|
||||
? $executionTime
|
||||
: $this->maxExecutionTime,
|
||||
minExecutionTime: $this->minExecutionTime->isZero() || $executionTime->isLessThan($this->minExecutionTime)
|
||||
? $executionTime
|
||||
: $this->minExecutionTime,
|
||||
lastSuccessTime: $this->lastSuccessTime,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
lastFallbackTime: $this->lastFallbackTime,
|
||||
createdAt: $this->createdAt ?? Timestamp::now(),
|
||||
updatedAt: Timestamp::now(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record circuit breaker trip
|
||||
*/
|
||||
public function recordCircuitBreakerTrip(): self
|
||||
{
|
||||
return new self(
|
||||
boundaryName: $this->boundaryName,
|
||||
totalExecutions: $this->totalExecutions,
|
||||
successfulExecutions: $this->successfulExecutions,
|
||||
failedExecutions: $this->failedExecutions,
|
||||
fallbackExecutions: $this->fallbackExecutions,
|
||||
timeoutExecutions: $this->timeoutExecutions,
|
||||
circuitBreakerTrips: $this->circuitBreakerTrips + 1,
|
||||
totalExecutionTime: $this->totalExecutionTime,
|
||||
averageExecutionTime: $this->averageExecutionTime,
|
||||
maxExecutionTime: $this->maxExecutionTime,
|
||||
minExecutionTime: $this->minExecutionTime,
|
||||
lastSuccessTime: $this->lastSuccessTime,
|
||||
lastFailureTime: $this->lastFailureTime,
|
||||
lastFallbackTime: $this->lastFallbackTime,
|
||||
createdAt: $this->createdAt ?? Timestamp::now(),
|
||||
updatedAt: Timestamp::now(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'boundary_name' => $this->boundaryName,
|
||||
'total_executions' => $this->totalExecutions,
|
||||
'successful_executions' => $this->successfulExecutions,
|
||||
'failed_executions' => $this->failedExecutions,
|
||||
'fallback_executions' => $this->fallbackExecutions,
|
||||
'timeout_executions' => $this->timeoutExecutions,
|
||||
'circuit_breaker_trips' => $this->circuitBreakerTrips,
|
||||
'success_rate' => $this->getSuccessRate()->toFloat(),
|
||||
'failure_rate' => $this->getFailureRate()->toFloat(),
|
||||
'fallback_rate' => $this->getFallbackRate()->toFloat(),
|
||||
'timeout_rate' => $this->getTimeoutRate()->toFloat(),
|
||||
'total_execution_time_ms' => $this->totalExecutionTime->toMilliseconds(),
|
||||
'average_execution_time_ms' => $this->averageExecutionTime->toMilliseconds(),
|
||||
'max_execution_time_ms' => $this->maxExecutionTime->toMilliseconds(),
|
||||
'min_execution_time_ms' => $this->minExecutionTime->toMilliseconds(),
|
||||
'last_success_time' => $this->lastSuccessTime?->toIsoString(),
|
||||
'last_failure_time' => $this->lastFailureTime?->toIsoString(),
|
||||
'last_fallback_time' => $this->lastFallbackTime?->toIsoString(),
|
||||
'health_status' => $this->getHealthStatus()->value,
|
||||
'is_healthy' => $this->isHealthy(),
|
||||
'has_frequent_circuit_trips' => $this->hasFrequentCircuitBreakerTrips(),
|
||||
'created_at' => $this->createdAt?->toIsoString(),
|
||||
'updated_at' => $this->updatedAt?->toIsoString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Middleware;
|
||||
|
||||
use App\Framework\ErrorBoundaries\BoundaryConfig;
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundary;
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Responses\JsonResponse;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Specialized middleware for API endpoints with detailed error boundaries
|
||||
*/
|
||||
final readonly class ApiErrorBoundaryMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorBoundaryFactory $boundaryFactory,
|
||||
private ?Logger $logger = null,
|
||||
private bool $includeDebugInfo = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$endpoint = $this->extractEndpoint($request);
|
||||
$boundary = $this->createApiBoundary($endpoint);
|
||||
|
||||
return $boundary->execute(
|
||||
operation: fn () => $next($context, $stateManager),
|
||||
fallback: fn () => $context->withResponse($this->createApiErrorResponse($request, $boundary))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create specialized boundary for API endpoints
|
||||
*/
|
||||
private function createApiBoundary(string $endpoint): ErrorBoundary
|
||||
{
|
||||
$config = BoundaryConfig::externalService(); // API-friendly config
|
||||
|
||||
return $this->boundaryFactory->create("api_{$endpoint}", $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create comprehensive API error response
|
||||
*/
|
||||
private function createApiErrorResponse($request, ErrorBoundary $boundary): JsonResponse
|
||||
{
|
||||
$requestId = $request->headers->get('X-Request-ID') ?? uniqid('req_');
|
||||
$timestamp = date(\DateTimeInterface::ISO8601);
|
||||
|
||||
$errorData = [
|
||||
'error' => [
|
||||
'code' => 'API_SERVICE_UNAVAILABLE',
|
||||
'message' => 'The API service is temporarily unavailable due to high load or maintenance.',
|
||||
'type' => 'service_unavailable',
|
||||
'timestamp' => $timestamp,
|
||||
'request_id' => $requestId,
|
||||
'retry_after' => 60, // seconds
|
||||
],
|
||||
'meta' => [
|
||||
'endpoint' => $request->uri->getPath(),
|
||||
'method' => $request->getMethod()->value,
|
||||
'boundary_used' => true,
|
||||
'fallback_response' => true,
|
||||
],
|
||||
'links' => [
|
||||
'status' => '/api/status',
|
||||
'documentation' => '/api/docs',
|
||||
'support' => '/api/support',
|
||||
],
|
||||
];
|
||||
|
||||
// Add debug information in development
|
||||
if ($this->includeDebugInfo) {
|
||||
$circuitHealth = $boundary->getCircuitHealth();
|
||||
if ($circuitHealth !== null) {
|
||||
$errorData['debug'] = [
|
||||
'circuit_breaker' => $circuitHealth,
|
||||
'boundary_name' => $circuitHealth['boundary_name'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$response = new JsonResponse($errorData, Status::SERVICE_UNAVAILABLE);
|
||||
|
||||
// Add retry-after header
|
||||
$response = $response->withHeader('Retry-After', '60');
|
||||
$response = $response->withHeader('X-Request-ID', $requestId);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract API endpoint name for boundary identification
|
||||
*/
|
||||
private function extractEndpoint($request): string
|
||||
{
|
||||
$path = $request->uri->getPath();
|
||||
$method = $request->getMethod()->value;
|
||||
|
||||
// Clean up path for boundary naming
|
||||
$cleanPath = trim($path, '/');
|
||||
$cleanPath = str_replace(['/', '-', '.'], '_', $cleanPath);
|
||||
$cleanPath = preg_replace('/[^a-zA-Z0-9_]/', '', $cleanPath);
|
||||
|
||||
if (empty($cleanPath)) {
|
||||
$cleanPath = 'root';
|
||||
}
|
||||
|
||||
return strtolower("{$method}_{$cleanPath}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Middleware;
|
||||
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Responses\JsonResponse;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
/**
|
||||
* Middleware that provides circuit breaker health information
|
||||
*/
|
||||
final readonly class CircuitBreakerHealthMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorBoundaryFactory $boundaryFactory,
|
||||
private array $monitoredBoundaries = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
|
||||
// Only handle health check requests
|
||||
if (! $this->isHealthCheckRequest($request)) {
|
||||
return $next($context, $stateManager);
|
||||
}
|
||||
|
||||
return $context->withResponse($this->createHealthResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a health check request
|
||||
*/
|
||||
private function isHealthCheckRequest($request): bool
|
||||
{
|
||||
$path = $request->uri->getPath();
|
||||
|
||||
return in_array($path, [
|
||||
'/health/circuit-breakers',
|
||||
'/api/health/circuit-breakers',
|
||||
'/admin/health/circuit-breakers',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create comprehensive health response
|
||||
*/
|
||||
private function createHealthResponse(): JsonResponse
|
||||
{
|
||||
$boundaries = $this->getMonitoredBoundaries();
|
||||
$healthData = [];
|
||||
$overallHealth = 'healthy';
|
||||
$unhealthyCount = 0;
|
||||
|
||||
foreach ($boundaries as $boundaryName) {
|
||||
$boundary = $this->boundaryFactory->create($boundaryName, \App\Framework\ErrorBoundaries\BoundaryConfig::development());
|
||||
$health = $boundary->getCircuitHealth();
|
||||
|
||||
if ($health !== null) {
|
||||
$healthData[$boundaryName] = $health;
|
||||
|
||||
if (! $health['is_healthy']) {
|
||||
$unhealthyCount++;
|
||||
if ($health['severity'] === 'error') {
|
||||
$overallHealth = 'critical';
|
||||
} elseif ($overallHealth === 'healthy') {
|
||||
$overallHealth = 'warning';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response = [
|
||||
'status' => $overallHealth,
|
||||
'timestamp' => date(\DateTimeInterface::ISO8601),
|
||||
'summary' => [
|
||||
'total_boundaries' => count($boundaries),
|
||||
'healthy_boundaries' => count($boundaries) - $unhealthyCount,
|
||||
'unhealthy_boundaries' => $unhealthyCount,
|
||||
'overall_health_score' => $this->calculateHealthScore($healthData),
|
||||
],
|
||||
'boundaries' => $healthData,
|
||||
'recommendations' => $this->generateRecommendations($healthData),
|
||||
];
|
||||
|
||||
$statusCode = match ($overallHealth) {
|
||||
'healthy' => Status::OK,
|
||||
'warning' => Status::OK, // Still OK but with warnings
|
||||
'critical' => Status::SERVICE_UNAVAILABLE,
|
||||
default => Status::OK,
|
||||
};
|
||||
|
||||
return new JsonResponse($response, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of boundaries to monitor
|
||||
*/
|
||||
private function getMonitoredBoundaries(): array
|
||||
{
|
||||
if (! empty($this->monitoredBoundaries)) {
|
||||
return $this->monitoredBoundaries;
|
||||
}
|
||||
|
||||
// Default boundaries to monitor
|
||||
return [
|
||||
'api_health',
|
||||
'database_connection',
|
||||
'external_payment_service',
|
||||
'external_email_service',
|
||||
'cache_service',
|
||||
'file_storage',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall health score (0-100)
|
||||
*/
|
||||
private function calculateHealthScore(array $healthData): int
|
||||
{
|
||||
if (empty($healthData)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($healthData as $health) {
|
||||
$boundaryScore = match ($health['severity']) {
|
||||
'info' => 100, // Healthy
|
||||
'warning' => 70, // Warning
|
||||
'error' => 20, // Critical
|
||||
default => 50, // Unknown
|
||||
};
|
||||
|
||||
$totalScore += $boundaryScore;
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count > 0 ? (int) round($totalScore / $count) : 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate actionable recommendations based on health data
|
||||
*/
|
||||
private function generateRecommendations(array $healthData): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($healthData as $boundaryName => $health) {
|
||||
if (! $health['is_healthy']) {
|
||||
$recommendation = $this->getRecommendationForBoundary($boundaryName, $health);
|
||||
if ($recommendation !== null) {
|
||||
$recommendations[] = $recommendation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add general recommendations
|
||||
if (count($recommendations) > 3) {
|
||||
$recommendations[] = [
|
||||
'type' => 'general',
|
||||
'priority' => 'high',
|
||||
'message' => 'Multiple circuit breakers are unhealthy. Consider system-wide health check.',
|
||||
'action' => 'Review system load and dependencies',
|
||||
];
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific recommendation for a boundary
|
||||
*/
|
||||
private function getRecommendationForBoundary(string $boundaryName, array $health): ?array
|
||||
{
|
||||
$recommendations = [
|
||||
'database_connection' => [
|
||||
'type' => 'database',
|
||||
'priority' => 'critical',
|
||||
'message' => 'Database connection circuit breaker is open',
|
||||
'action' => 'Check database connectivity and performance',
|
||||
],
|
||||
'external_payment_service' => [
|
||||
'type' => 'external_service',
|
||||
'priority' => 'high',
|
||||
'message' => 'Payment service circuit breaker is failing',
|
||||
'action' => 'Verify payment service status and API connectivity',
|
||||
],
|
||||
'cache_service' => [
|
||||
'type' => 'cache',
|
||||
'priority' => 'medium',
|
||||
'message' => 'Cache service circuit breaker is degraded',
|
||||
'action' => 'Check Redis/cache service health and memory usage',
|
||||
],
|
||||
];
|
||||
|
||||
$recommendation = $recommendations[$boundaryName] ?? null;
|
||||
|
||||
if ($recommendation !== null) {
|
||||
$recommendation['boundary'] = $boundaryName;
|
||||
$recommendation['state'] = $health['state'];
|
||||
$recommendation['failure_count'] = $health['failure_count'];
|
||||
}
|
||||
|
||||
return $recommendation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Middleware;
|
||||
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Responses\JsonResponse;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
/**
|
||||
* HTTP Middleware that wraps requests in error boundaries
|
||||
*/
|
||||
final readonly class ErrorBoundaryMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorBoundaryFactory $boundaryFactory,
|
||||
private ?Logger $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$routeName = $this->extractRouteName($request, $context);
|
||||
$boundary = $this->boundaryFactory->createForRoute($routeName);
|
||||
|
||||
return $boundary->execute(
|
||||
operation: fn () => $next($context, $stateManager),
|
||||
fallback: fn () => $context->withResponse($this->createFallbackResponse($request, $context))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a graceful fallback response when the main operation fails
|
||||
*/
|
||||
private function createFallbackResponse($request, MiddlewareContext $context)
|
||||
{
|
||||
$acceptsJson = $this->acceptsJson($request);
|
||||
|
||||
if ($acceptsJson) {
|
||||
return $this->createJsonFallbackResponse($request);
|
||||
}
|
||||
|
||||
return $this->createHtmlFallbackResponse($request, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON fallback response for API requests
|
||||
*/
|
||||
private function createJsonFallbackResponse($request): JsonResponse
|
||||
{
|
||||
$errorData = [
|
||||
'error' => [
|
||||
'code' => 'SERVICE_TEMPORARILY_UNAVAILABLE',
|
||||
'message' => 'The service is temporarily unavailable. Please try again later.',
|
||||
'timestamp' => date(\DateTimeInterface::ISO8601),
|
||||
'request_id' => $request->headers->get('X-Request-ID') ?? uniqid(),
|
||||
],
|
||||
'fallback' => true,
|
||||
];
|
||||
|
||||
return new JsonResponse($errorData, Status::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTML fallback response for web requests
|
||||
*/
|
||||
private function createHtmlFallbackResponse($request, MiddlewareContext $context)
|
||||
{
|
||||
$fallbackHtml = $this->getFallbackHtmlContent($request);
|
||||
|
||||
return new ViewResult($fallbackHtml, [
|
||||
'request' => $request,
|
||||
'timestamp' => date(\DateTimeInterface::ISO8601),
|
||||
'request_id' => $request->headers->get('X-Request-ID') ?? uniqid(),
|
||||
], Status::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback HTML content
|
||||
*/
|
||||
private function getFallbackHtmlContent($request): string
|
||||
{
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Service Temporarily Unavailable</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.retry-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.retry-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.details {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">⚠️</div>
|
||||
<h1>Service Temporarily Unavailable</h1>
|
||||
<p>We're experiencing technical difficulties at the moment. Our team has been notified and is working to resolve the issue.</p>
|
||||
<button class="retry-btn" onclick="window.location.reload()">Try Again</button>
|
||||
<div class="details">
|
||||
<p>If the problem persists, please contact our support team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract route name from request/context for boundary naming
|
||||
*/
|
||||
private function extractRouteName($request, MiddlewareContext $context): string
|
||||
{
|
||||
// Try to get route name from context
|
||||
$routeName = $context->get('route_name');
|
||||
if ($routeName !== null) {
|
||||
return (string) $routeName;
|
||||
}
|
||||
|
||||
// Fallback to request path
|
||||
$path = $request->uri->getPath();
|
||||
$method = $request->getMethod()->value;
|
||||
|
||||
return "{$method}_{$path}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request accepts JSON response
|
||||
*/
|
||||
private function acceptsJson($request): bool
|
||||
{
|
||||
$acceptHeader = $request->headers->get('Accept', '');
|
||||
|
||||
return str_contains($acceptHeader, 'application/json') ||
|
||||
str_contains($acceptHeader, 'application/*') ||
|
||||
$request->uri->getPath() === '' ||
|
||||
str_starts_with($request->uri->getPath(), '/api/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Middleware;
|
||||
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
|
||||
use App\Framework\Http\MiddlewareManager;
|
||||
use App\Framework\Http\MiddlewarePriority;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Registry for error boundary middleware components
|
||||
*/
|
||||
final readonly class ErrorBoundaryMiddlewareRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private MiddlewareManager $middlewareManager,
|
||||
private ErrorBoundaryFactory $boundaryFactory,
|
||||
private MiddlewareConfiguration $configuration,
|
||||
private ?Logger $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all error boundary middleware
|
||||
*/
|
||||
public function registerAll(): void
|
||||
{
|
||||
$this->registerMainMiddleware();
|
||||
|
||||
if ($this->configuration->enableApiMiddleware) {
|
||||
$this->registerApiMiddleware();
|
||||
}
|
||||
|
||||
if ($this->configuration->enableHealthMiddleware) {
|
||||
$this->registerHealthMiddleware();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register main error boundary middleware
|
||||
*/
|
||||
public function registerMainMiddleware(): void
|
||||
{
|
||||
$middleware = new ErrorBoundaryMiddleware(
|
||||
boundaryFactory: $this->boundaryFactory,
|
||||
logger: $this->logger,
|
||||
);
|
||||
|
||||
$this->middlewareManager->register(
|
||||
middleware: $middleware,
|
||||
priority: $this->configuration->priority,
|
||||
condition: fn ($request) => $this->shouldApplyMainMiddleware($request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register API-specific error boundary middleware
|
||||
*/
|
||||
public function registerApiMiddleware(): void
|
||||
{
|
||||
$middleware = new ApiErrorBoundaryMiddleware(
|
||||
boundaryFactory: $this->boundaryFactory,
|
||||
logger: $this->logger,
|
||||
includeDebugInfo: $this->configuration->includeDebugInfo,
|
||||
);
|
||||
|
||||
$this->middlewareManager->register(
|
||||
middleware: $middleware,
|
||||
priority: $this->configuration->priority + 10, // Higher priority for API
|
||||
condition: fn ($request) => $this->shouldApplyApiMiddleware($request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register health check middleware
|
||||
*/
|
||||
public function registerHealthMiddleware(): void
|
||||
{
|
||||
$middleware = new CircuitBreakerHealthMiddleware(
|
||||
boundaryFactory: $this->boundaryFactory,
|
||||
monitoredBoundaries: $this->getMonitoredBoundaries(),
|
||||
);
|
||||
|
||||
$this->middlewareManager->register(
|
||||
middleware: $middleware,
|
||||
priority: MiddlewarePriority::HIGH, // Health checks should run early
|
||||
condition: fn ($request) => $this->shouldApplyHealthMiddleware($request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register middleware with custom configuration
|
||||
*/
|
||||
public function registerCustom(
|
||||
string $middlewareClass,
|
||||
array $constructorArgs,
|
||||
int $priority,
|
||||
?callable $condition = null
|
||||
): void {
|
||||
$middleware = new $middlewareClass(...$constructorArgs);
|
||||
|
||||
$this->middlewareManager->register(
|
||||
middleware: $middleware,
|
||||
priority: $priority,
|
||||
condition: $condition,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if main middleware should be applied
|
||||
*/
|
||||
private function shouldApplyMainMiddleware($request): bool
|
||||
{
|
||||
$path = $request->uri->getPath();
|
||||
|
||||
// Don't apply to health endpoints (they have their own middleware)
|
||||
if ($this->configuration->isHealthPath($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't apply to API endpoints if API middleware is enabled
|
||||
if ($this->configuration->enableApiMiddleware && $this->configuration->isApiPath($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->configuration->shouldEnableForRoute($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API middleware should be applied
|
||||
*/
|
||||
private function shouldApplyApiMiddleware($request): bool
|
||||
{
|
||||
$path = $request->uri->getPath();
|
||||
|
||||
return $this->configuration->isApiPath($path) &&
|
||||
$this->configuration->shouldEnableForRoute($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if health middleware should be applied
|
||||
*/
|
||||
private function shouldApplyHealthMiddleware($request): bool
|
||||
{
|
||||
$path = $request->uri->getPath();
|
||||
|
||||
return $this->configuration->isHealthPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of boundaries to monitor for health checks
|
||||
*/
|
||||
private function getMonitoredBoundaries(): array
|
||||
{
|
||||
return [
|
||||
'api_health',
|
||||
'database_connection',
|
||||
'external_payment_service',
|
||||
'external_email_service',
|
||||
'cache_service',
|
||||
'file_storage',
|
||||
'mail_service',
|
||||
'notification_service',
|
||||
'search_service',
|
||||
'analytics_service',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister all error boundary middleware
|
||||
*/
|
||||
public function unregisterAll(): void
|
||||
{
|
||||
$this->middlewareManager->unregister(ErrorBoundaryMiddleware::class);
|
||||
$this->middlewareManager->unregister(ApiErrorBoundaryMiddleware::class);
|
||||
$this->middlewareManager->unregister(CircuitBreakerHealthMiddleware::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get middleware statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'configuration' => [
|
||||
'enable_for_all_routes' => $this->configuration->enableForAllRoutes,
|
||||
'enabled_routes_count' => count($this->configuration->enabledRoutes),
|
||||
'excluded_routes_count' => count($this->configuration->excludedRoutes),
|
||||
'api_middleware_enabled' => $this->configuration->enableApiMiddleware,
|
||||
'health_middleware_enabled' => $this->configuration->enableHealthMiddleware,
|
||||
'debug_info_enabled' => $this->configuration->includeDebugInfo,
|
||||
],
|
||||
'middleware_registered' => [
|
||||
'main_middleware' => $this->middlewareManager->isRegistered(ErrorBoundaryMiddleware::class),
|
||||
'api_middleware' => $this->middlewareManager->isRegistered(ApiErrorBoundaryMiddleware::class),
|
||||
'health_middleware' => $this->middlewareManager->isRegistered(CircuitBreakerHealthMiddleware::class),
|
||||
],
|
||||
'monitored_boundaries_count' => count($this->getMonitoredBoundaries()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Middleware;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\ErrorBoundaries\BoundaryConfig;
|
||||
use App\Framework\ErrorBoundaries\RetryStrategy;
|
||||
|
||||
/**
|
||||
* Configuration for error boundary middleware
|
||||
*/
|
||||
final readonly class MiddlewareConfiguration
|
||||
{
|
||||
public function __construct(
|
||||
public bool $enableForAllRoutes = false,
|
||||
public array $enabledRoutes = [],
|
||||
public array $excludedRoutes = [],
|
||||
public bool $enableApiMiddleware = true,
|
||||
public bool $enableHealthMiddleware = true,
|
||||
public array $routeConfigurations = [],
|
||||
public array $apiPaths = ['/api', '/v1', '/v2'],
|
||||
public array $healthPaths = [
|
||||
'/health/circuit-breakers',
|
||||
'/api/health/circuit-breakers',
|
||||
'/admin/health/circuit-breakers',
|
||||
],
|
||||
public bool $includeDebugInfo = false,
|
||||
public int $priority = 100,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create development configuration
|
||||
*/
|
||||
public static function development(): self
|
||||
{
|
||||
return new self(
|
||||
enableForAllRoutes: true,
|
||||
enableApiMiddleware: true,
|
||||
enableHealthMiddleware: true,
|
||||
includeDebugInfo: true,
|
||||
excludedRoutes: [
|
||||
'/assets/*',
|
||||
'/css/*',
|
||||
'/js/*',
|
||||
'/favicon.ico',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create production configuration
|
||||
*/
|
||||
public static function production(): self
|
||||
{
|
||||
return new self(
|
||||
enableForAllRoutes: false,
|
||||
enabledRoutes: [
|
||||
'/api/*',
|
||||
'/admin/*',
|
||||
'/critical/*',
|
||||
],
|
||||
enableApiMiddleware: true,
|
||||
enableHealthMiddleware: true,
|
||||
includeDebugInfo: false,
|
||||
excludedRoutes: [
|
||||
'/assets/*',
|
||||
'/public/*',
|
||||
'/static/*',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API-only configuration
|
||||
*/
|
||||
public static function apiOnly(): self
|
||||
{
|
||||
return new self(
|
||||
enableForAllRoutes: false,
|
||||
enabledRoutes: ['/api/*'],
|
||||
enableApiMiddleware: true,
|
||||
enableHealthMiddleware: true,
|
||||
apiPaths: ['/api', '/v1', '/v2', '/graphql'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if middleware should be enabled for a route
|
||||
*/
|
||||
public function shouldEnableForRoute(string $routePath): bool
|
||||
{
|
||||
// First check exclusions
|
||||
if ($this->isExcludedRoute($routePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If enabled for all routes, enable unless excluded
|
||||
if ($this->enableForAllRoutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check specific enabled routes
|
||||
return $this->isEnabledRoute($routePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route is specifically excluded
|
||||
*/
|
||||
public function isExcludedRoute(string $routePath): bool
|
||||
{
|
||||
foreach ($this->excludedRoutes as $pattern) {
|
||||
if ($this->matchesPattern($routePath, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route is specifically enabled
|
||||
*/
|
||||
public function isEnabledRoute(string $routePath): bool
|
||||
{
|
||||
foreach ($this->enabledRoutes as $pattern) {
|
||||
if ($this->matchesPattern($routePath, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route is an API path
|
||||
*/
|
||||
public function isApiPath(string $routePath): bool
|
||||
{
|
||||
foreach ($this->apiPaths as $apiPath) {
|
||||
if (str_starts_with($routePath, $apiPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route is a health check path
|
||||
*/
|
||||
public function isHealthPath(string $routePath): bool
|
||||
{
|
||||
return in_array($routePath, $this->healthPaths, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for specific route
|
||||
*/
|
||||
public function getRouteConfiguration(string $routePath): ?BoundaryConfig
|
||||
{
|
||||
// Check for exact match first
|
||||
if (isset($this->routeConfigurations[$routePath])) {
|
||||
return $this->routeConfigurations[$routePath];
|
||||
}
|
||||
|
||||
// Check for pattern matches
|
||||
foreach ($this->routeConfigurations as $pattern => $config) {
|
||||
if ($this->matchesPattern($routePath, $pattern)) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add route-specific configuration
|
||||
*/
|
||||
public function withRouteConfiguration(string $routePattern, BoundaryConfig $config): self
|
||||
{
|
||||
$configurations = $this->routeConfigurations;
|
||||
$configurations[$routePattern] = $config;
|
||||
|
||||
return new self(
|
||||
enableForAllRoutes: $this->enableForAllRoutes,
|
||||
enabledRoutes: $this->enabledRoutes,
|
||||
excludedRoutes: $this->excludedRoutes,
|
||||
enableApiMiddleware: $this->enableApiMiddleware,
|
||||
enableHealthMiddleware: $this->enableHealthMiddleware,
|
||||
routeConfigurations: $configurations,
|
||||
apiPaths: $this->apiPaths,
|
||||
healthPaths: $this->healthPaths,
|
||||
includeDebugInfo: $this->includeDebugInfo,
|
||||
priority: $this->priority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default route configurations
|
||||
*/
|
||||
public function getDefaultRouteConfigurations(): array
|
||||
{
|
||||
return [
|
||||
'/api/*' => BoundaryConfig::externalService(),
|
||||
'/admin/*' => BoundaryConfig::critical(),
|
||||
'/auth/*' => BoundaryConfig::failFast(),
|
||||
'/public/*' => BoundaryConfig::ui(),
|
||||
'/health/*' => new BoundaryConfig(
|
||||
maxRetries: 0,
|
||||
retryStrategy: RetryStrategy::FIXED,
|
||||
baseDelay: Duration::fromMilliseconds(0),
|
||||
maxDelay: Duration::fromMilliseconds(0),
|
||||
circuitBreakerEnabled: false,
|
||||
enableMetrics: true
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function matchesPattern(string $path, string $pattern): bool
|
||||
{
|
||||
// Convert shell-style wildcards to regex
|
||||
$regex = str_replace('*', '.*', preg_quote($pattern, '/'));
|
||||
|
||||
return (bool) preg_match("/^{$regex}$/", $path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries\Middleware;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\ErrorBoundaries\ErrorBoundaryFactory;
|
||||
use App\Framework\Http\MiddlewareManager;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Service provider for error boundary middleware
|
||||
*/
|
||||
final readonly class MiddlewareServiceProvider
|
||||
{
|
||||
public function __construct(
|
||||
private MiddlewareConfiguration $configuration,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function initialize(Container $container): void
|
||||
{
|
||||
$this->registerMiddlewareConfiguration($container);
|
||||
$this->registerMiddlewareComponents($container);
|
||||
$this->registerMiddlewareRegistry($container);
|
||||
$this->initializeMiddleware($container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register middleware configuration
|
||||
*/
|
||||
private function registerMiddlewareConfiguration(Container $container): void
|
||||
{
|
||||
$container->bindInstance(
|
||||
MiddlewareConfiguration::class,
|
||||
$this->configuration
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register middleware components
|
||||
*/
|
||||
private function registerMiddlewareComponents(Container $container): void
|
||||
{
|
||||
// Register main error boundary middleware
|
||||
$container->bind(
|
||||
ErrorBoundaryMiddleware::class,
|
||||
function (Container $container) {
|
||||
return new ErrorBoundaryMiddleware(
|
||||
boundaryFactory: $container->get(ErrorBoundaryFactory::class),
|
||||
logger: $container->getOptional(Logger::class),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Register API error boundary middleware
|
||||
$container->bind(
|
||||
ApiErrorBoundaryMiddleware::class,
|
||||
function (Container $container) {
|
||||
$config = $container->get(MiddlewareConfiguration::class);
|
||||
|
||||
return new ApiErrorBoundaryMiddleware(
|
||||
boundaryFactory: $container->get(ErrorBoundaryFactory::class),
|
||||
logger: $container->getOptional(Logger::class),
|
||||
includeDebugInfo: $config->includeDebugInfo,
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Register circuit breaker health middleware
|
||||
$container->bind(
|
||||
CircuitBreakerHealthMiddleware::class,
|
||||
function (Container $container) {
|
||||
return new CircuitBreakerHealthMiddleware(
|
||||
boundaryFactory: $container->get(ErrorBoundaryFactory::class),
|
||||
monitoredBoundaries: $this->getDefaultMonitoredBoundaries(),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register middleware registry
|
||||
*/
|
||||
private function registerMiddlewareRegistry(Container $container): void
|
||||
{
|
||||
$container->bind(
|
||||
ErrorBoundaryMiddlewareRegistry::class,
|
||||
function (Container $container) {
|
||||
return new ErrorBoundaryMiddlewareRegistry(
|
||||
middlewareManager: $container->get(MiddlewareManager::class),
|
||||
boundaryFactory: $container->get(ErrorBoundaryFactory::class),
|
||||
configuration: $container->get(MiddlewareConfiguration::class),
|
||||
logger: $container->getOptional(Logger::class),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and register middleware
|
||||
*/
|
||||
private function initializeMiddleware(Container $container): void
|
||||
{
|
||||
// Get the registry and register all middleware
|
||||
$registry = $container->get(ErrorBoundaryMiddlewareRegistry::class);
|
||||
$registry->registerAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create service provider with development configuration
|
||||
*/
|
||||
public static function development(): self
|
||||
{
|
||||
return new self(MiddlewareConfiguration::development());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create service provider with production configuration
|
||||
*/
|
||||
public static function production(): self
|
||||
{
|
||||
return new self(MiddlewareConfiguration::production());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create service provider with API-only configuration
|
||||
*/
|
||||
public static function apiOnly(): self
|
||||
{
|
||||
return new self(MiddlewareConfiguration::apiOnly());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create service provider with custom configuration
|
||||
*/
|
||||
public static function custom(MiddlewareConfiguration $configuration): self
|
||||
{
|
||||
return new self($configuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default monitored boundaries
|
||||
*/
|
||||
private function getDefaultMonitoredBoundaries(): array
|
||||
{
|
||||
return [
|
||||
'api_health',
|
||||
'database_connection',
|
||||
'external_payment_service',
|
||||
'external_email_service',
|
||||
'cache_service',
|
||||
'file_storage',
|
||||
'mail_service',
|
||||
'notification_service',
|
||||
'search_service',
|
||||
'analytics_service',
|
||||
];
|
||||
}
|
||||
}
|
||||
223
src/Framework/ErrorBoundaries/Middleware/README.md
Normal file
223
src/Framework/ErrorBoundaries/Middleware/README.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# ErrorBoundary Middleware System
|
||||
|
||||
Complete HTTP middleware system for automatic error boundary integration in web applications.
|
||||
|
||||
## Components
|
||||
|
||||
### Core Middleware Classes
|
||||
|
||||
- **`ErrorBoundaryMiddleware`** - Main middleware for web requests with graceful fallback responses
|
||||
- **`ApiErrorBoundaryMiddleware`** - Specialized API middleware with detailed JSON error responses
|
||||
- **`CircuitBreakerHealthMiddleware`** - Health check endpoints for monitoring circuit breaker states
|
||||
|
||||
### Configuration & Management
|
||||
|
||||
- **`MiddlewareConfiguration`** - Centralized configuration for all middleware components
|
||||
- **`ErrorBoundaryMiddlewareRegistry`** - Registration and management of middleware components
|
||||
- **`MiddlewareServiceProvider`** - Dependency injection and service initialization
|
||||
|
||||
## Features
|
||||
|
||||
### Automatic Error Boundaries
|
||||
- Wraps HTTP requests in error boundaries
|
||||
- Provides graceful fallback responses for failed operations
|
||||
- Supports both JSON (API) and HTML (web) responses
|
||||
- Route-based configuration and conditional middleware application
|
||||
|
||||
### Circuit Breaker Integration
|
||||
- Automatic circuit breaker management for routes
|
||||
- Health check endpoints with comprehensive status information
|
||||
- Configurable thresholds and recovery mechanisms
|
||||
- Real-time monitoring and alerting capabilities
|
||||
|
||||
### Flexible Configuration
|
||||
- Development, production, and API-only presets
|
||||
- Route-pattern matching with wildcards
|
||||
- Granular enable/disable controls per route type
|
||||
- Debug information inclusion for development environments
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```php
|
||||
// In your application bootstrap
|
||||
$serviceProvider = MiddlewareServiceProvider::development();
|
||||
$serviceProvider->initialize($container);
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```php
|
||||
$config = new MiddlewareConfiguration(
|
||||
enableForAllRoutes: false,
|
||||
enabledRoutes: ['/api/*', '/admin/*'],
|
||||
excludedRoutes: ['/assets/*', '/health/*'],
|
||||
enableApiMiddleware: true,
|
||||
enableHealthMiddleware: true,
|
||||
includeDebugInfo: false,
|
||||
);
|
||||
|
||||
$serviceProvider = MiddlewareServiceProvider::custom($config);
|
||||
$serviceProvider->initialize($container);
|
||||
```
|
||||
|
||||
### Route-Specific Configuration
|
||||
|
||||
```php
|
||||
$config = MiddlewareConfiguration::production()
|
||||
->withRouteConfiguration('/api/payments/*', BoundaryConfig::critical())
|
||||
->withRouteConfiguration('/api/notifications/*', BoundaryConfig::externalService());
|
||||
|
||||
$serviceProvider = MiddlewareServiceProvider::custom($config);
|
||||
```
|
||||
|
||||
## Configuration Presets
|
||||
|
||||
### Development Mode
|
||||
```php
|
||||
MiddlewareConfiguration::development()
|
||||
```
|
||||
- Enabled for all routes
|
||||
- Debug information included
|
||||
- Health monitoring enabled
|
||||
- Excludes only static assets
|
||||
|
||||
### Production Mode
|
||||
```php
|
||||
MiddlewareConfiguration::production()
|
||||
```
|
||||
- Selective route enabling
|
||||
- No debug information
|
||||
- Optimized for performance
|
||||
- Comprehensive exclusions
|
||||
|
||||
### API-Only Mode
|
||||
```php
|
||||
MiddlewareConfiguration::apiOnly()
|
||||
```
|
||||
- Only API routes enabled
|
||||
- JSON responses only
|
||||
- GraphQL support included
|
||||
- Health monitoring enabled
|
||||
|
||||
## Health Check Endpoints
|
||||
|
||||
### Circuit Breaker Status
|
||||
```
|
||||
GET /health/circuit-breakers
|
||||
GET /api/health/circuit-breakers
|
||||
GET /admin/health/circuit-breakers
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy|warning|critical",
|
||||
"timestamp": "2024-01-22T14:30:00+00:00",
|
||||
"summary": {
|
||||
"total_boundaries": 6,
|
||||
"healthy_boundaries": 5,
|
||||
"unhealthy_boundaries": 1,
|
||||
"overall_health_score": 85
|
||||
},
|
||||
"boundaries": {
|
||||
"api_health": {
|
||||
"is_healthy": true,
|
||||
"state": "CLOSED",
|
||||
"failure_count": 0,
|
||||
"severity": "info"
|
||||
}
|
||||
},
|
||||
"recommendations": [
|
||||
{
|
||||
"type": "database",
|
||||
"priority": "critical",
|
||||
"message": "Database connection circuit breaker is open",
|
||||
"action": "Check database connectivity and performance"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Response Formats
|
||||
|
||||
### Web Requests (HTML)
|
||||
- Beautiful fallback pages with retry functionality
|
||||
- User-friendly error messages
|
||||
- Responsive design with modern styling
|
||||
- Request tracking for debugging
|
||||
|
||||
### API Requests (JSON)
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "API_SERVICE_UNAVAILABLE",
|
||||
"message": "The API service is temporarily unavailable due to high load or maintenance.",
|
||||
"type": "service_unavailable",
|
||||
"timestamp": "2024-01-22T14:30:00+00:00",
|
||||
"request_id": "req_abc123",
|
||||
"retry_after": 60
|
||||
},
|
||||
"meta": {
|
||||
"endpoint": "/api/users",
|
||||
"method": "GET",
|
||||
"boundary_used": true,
|
||||
"fallback_response": true
|
||||
},
|
||||
"links": {
|
||||
"status": "/api/status",
|
||||
"documentation": "/api/docs",
|
||||
"support": "/api/support"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Middleware Statistics
|
||||
```php
|
||||
$registry = $container->get(ErrorBoundaryMiddlewareRegistry::class);
|
||||
$stats = $registry->getStatistics();
|
||||
```
|
||||
|
||||
### Event Integration
|
||||
- Boundary execution events
|
||||
- Circuit breaker state changes
|
||||
- Fallback execution tracking
|
||||
- Performance metrics collection
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Route Configuration
|
||||
- Use specific patterns over broad wildcards
|
||||
- Configure critical routes with appropriate boundaries
|
||||
- Exclude static assets and health checks from main middleware
|
||||
- Test fallback responses in development
|
||||
|
||||
### Circuit Breaker Tuning
|
||||
- Set appropriate failure thresholds for each service type
|
||||
- Configure realistic timeout values
|
||||
- Monitor and adjust based on actual traffic patterns
|
||||
- Implement proper alerting for circuit breaker events
|
||||
|
||||
### Performance Optimization
|
||||
- Use conditional middleware application
|
||||
- Configure appropriate middleware priorities
|
||||
- Monitor middleware execution time
|
||||
- Cache boundary configurations where possible
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Framework Integration
|
||||
- Integrates with framework's HTTP middleware system
|
||||
- Uses dependency injection for component management
|
||||
- Leverages event system for observability
|
||||
- Compatible with existing error handling
|
||||
|
||||
### External Dependencies
|
||||
- **ErrorBoundaryFactory** - For boundary creation
|
||||
- **StateManager** - For circuit breaker persistence
|
||||
- **Logger** - For operational logging
|
||||
- **EventBus** - For event publishing
|
||||
- **MiddlewareManager** - For HTTP middleware registration
|
||||
37
src/Framework/ErrorBoundaries/RetryStrategy.php
Normal file
37
src/Framework/ErrorBoundaries/RetryStrategy.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorBoundaries;
|
||||
|
||||
/**
|
||||
* Retry strategies for error boundaries
|
||||
*/
|
||||
enum RetryStrategy: string
|
||||
{
|
||||
case FIXED = 'fixed';
|
||||
case LINEAR = 'linear';
|
||||
case EXPONENTIAL = 'exponential';
|
||||
case EXPONENTIAL_JITTER = 'exponential_jitter';
|
||||
|
||||
/**
|
||||
* Get a human-readable description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FIXED => 'Fixed delay between retries',
|
||||
self::LINEAR => 'Linear increase in delay',
|
||||
self::EXPONENTIAL => 'Exponential backoff',
|
||||
self::EXPONENTIAL_JITTER => 'Exponential backoff with random jitter',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the strategy includes randomization
|
||||
*/
|
||||
public function hasJitter(): bool
|
||||
{
|
||||
return $this === self::EXPONENTIAL_JITTER;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user