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:
504
src/Framework/CircuitBreaker/CircuitBreaker.php
Normal file
504
src/Framework/CircuitBreaker/CircuitBreaker.php
Normal file
@@ -0,0 +1,504 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\CircuitBreaker\Events\CircuitBreakerClosed;
|
||||
use App\Framework\CircuitBreaker\Events\CircuitBreakerEventPublisher;
|
||||
use App\Framework\CircuitBreaker\Events\CircuitBreakerHalfOpened;
|
||||
use App\Framework\CircuitBreaker\Events\CircuitBreakerOpened;
|
||||
use App\Framework\CircuitBreaker\Registry\ServiceRegistry;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Circuit Breaker Pattern Implementation
|
||||
*
|
||||
* Schützt das System vor wiederholten Fehlern durch temporäres Blockieren von Requests
|
||||
* nach einer bestimmten Anzahl von Fehlern.
|
||||
*
|
||||
* States:
|
||||
* - CLOSED: Normal operation, alle Requests werden durchgelassen
|
||||
* - OPEN: Service ist als fehlerhaft markiert, alle Requests werden abgelehnt
|
||||
* - HALF_OPEN: Test-Phase, limitierte Requests werden durchgelassen
|
||||
*/
|
||||
final readonly class CircuitBreaker implements CircuitBreakerInterface
|
||||
{
|
||||
private const int DEFAULT_FAILURE_THRESHOLD = 5;
|
||||
private const int DEFAULT_RECOVERY_TIMEOUT = 60; // seconds
|
||||
private const int DEFAULT_HALF_OPEN_MAX_ATTEMPTS = 3;
|
||||
private const int DEFAULT_SUCCESS_THRESHOLD = 3;
|
||||
|
||||
private string $cachePrefix;
|
||||
|
||||
public function __construct(
|
||||
private Cache $cache,
|
||||
private Clock $clock,
|
||||
private ?Logger $logger = null,
|
||||
private ?CircuitBreakerEventPublisher $eventPublisher = null,
|
||||
private ?ServiceRegistry $serviceRegistry = null,
|
||||
private string $namespace = 'default'
|
||||
) {
|
||||
$this->cachePrefix = 'circuit_breaker_' . $this->namespace . ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Circuit Breaker für einen Service offen ist
|
||||
*
|
||||
* @throws CircuitBreakerException wenn der Circuit Breaker offen ist
|
||||
*/
|
||||
public function check(string $service, ?CircuitBreakerConfig $config = null): void
|
||||
{
|
||||
// Register service in registry
|
||||
$this->registerService($service);
|
||||
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$state = $this->getState($service, $config);
|
||||
|
||||
if ($state === CircuitState::OPEN) {
|
||||
$metrics = $this->getMetrics($service);
|
||||
|
||||
// Prüfen ob Recovery Timeout abgelaufen ist
|
||||
if ($this->isRecoveryTimeoutExpired($service, $config)) {
|
||||
$this->transitionToHalfOpen($service, $config);
|
||||
$this->log('info', "Circuit breaker for '{$service}' transitioned to HALF_OPEN");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Circuit ist offen und Timeout noch nicht abgelaufen
|
||||
$retryAfter = $this->calculateRetryAfter($service, $config);
|
||||
|
||||
throw new CircuitBreakerException(
|
||||
service: $service,
|
||||
state: CircuitState::OPEN,
|
||||
failureCount: $metrics->failureCount,
|
||||
retryAfterSeconds: $retryAfter
|
||||
);
|
||||
}
|
||||
|
||||
if ($state === CircuitState::HALF_OPEN) {
|
||||
$halfOpenAttempts = $this->getHalfOpenAttempts($service);
|
||||
|
||||
if ($halfOpenAttempts >= $config->halfOpenMaxAttempts) {
|
||||
// Zu viele Versuche im HALF_OPEN State, zurück zu OPEN
|
||||
$this->transitionToOpen($service, 'Max half-open attempts exceeded', $config);
|
||||
|
||||
throw new CircuitBreakerException(
|
||||
service: $service,
|
||||
state: CircuitState::OPEN,
|
||||
failureCount: $this->getMetrics($service)->failureCount,
|
||||
retryAfterSeconds: $config->recoveryTimeout->toCacheSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
// Request im HALF_OPEN State erlauben
|
||||
$this->incrementHalfOpenAttempts($service, $config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert einen erfolgreichen Aufruf
|
||||
*/
|
||||
public function recordSuccess(string $service, ?CircuitBreakerConfig $config = null): void
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$state = $this->getState($service, $config);
|
||||
|
||||
if ($state === CircuitState::HALF_OPEN) {
|
||||
$successCount = $this->incrementSuccessCount($service, $config);
|
||||
|
||||
if ($successCount >= $config->successThreshold) {
|
||||
// Genug erfolgreiche Aufrufe, Circuit schließen
|
||||
$this->transitionToClosed($service, $config);
|
||||
$this->log('info', "Circuit breaker for '{$service}' transitioned to CLOSED after {$successCount} successes");
|
||||
}
|
||||
} else {
|
||||
// Im CLOSED State Success Count zurücksetzen
|
||||
$this->resetMetrics($service);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert einen fehlgeschlagenen Aufruf
|
||||
*/
|
||||
public function recordFailure(string $service, Throwable $exception, ?CircuitBreakerConfig $config = null): void
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
|
||||
// Check if this exception should trigger the circuit breaker
|
||||
if (! $config->shouldTriggerOnException($exception, $service)) {
|
||||
$this->log('debug', "Exception ignored by failure predicate for service '{$service}': " . get_class($exception));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->getState($service, $config);
|
||||
|
||||
if ($state === CircuitState::HALF_OPEN) {
|
||||
// Fehler im HALF_OPEN State führt sofort zu OPEN
|
||||
$this->transitionToOpen($service, 'Failure in HALF_OPEN state: ' . $exception->getMessage(), $config);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($state === CircuitState::CLOSED) {
|
||||
$failureCount = $this->incrementFailureCount($service, $config);
|
||||
|
||||
if ($failureCount >= $config->failureThreshold) {
|
||||
// Failure Threshold erreicht, Circuit öffnen
|
||||
$this->transitionToOpen($service, 'Failure threshold exceeded: ' . $exception->getMessage(), $config);
|
||||
$this->log('warning', "Circuit breaker for '{$service}' opened after {$failureCount} failures");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine Operation mit Circuit Breaker Schutz aus
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $operation
|
||||
* @return T
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function execute(string $service, callable $operation, ?CircuitBreakerConfig $config = null): mixed
|
||||
{
|
||||
$this->check($service, $config);
|
||||
|
||||
try {
|
||||
$result = $operation();
|
||||
$this->recordSuccess($service, $config);
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$this->recordFailure($service, $e, $config);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuellen Zustand des Circuit Breakers zurück
|
||||
*/
|
||||
public function getState(string $service, ?CircuitBreakerConfig $config = null): CircuitState
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$stateData = $this->cache->get($this->getStateKey($service));
|
||||
|
||||
if (! $stateData->isHit) {
|
||||
return CircuitState::CLOSED;
|
||||
}
|
||||
|
||||
// Ensure $stateData->value is an array and has a 'state' key
|
||||
if (! is_array($stateData->value) || ! isset($stateData->value['state'])) {
|
||||
$this->log('warning', "Invalid state data format in cache for service '{$service}'");
|
||||
|
||||
return CircuitState::CLOSED;
|
||||
}
|
||||
|
||||
$state = CircuitState::from($stateData->value['state']);
|
||||
|
||||
// Prüfen ob OPEN State abgelaufen ist
|
||||
if ($state === CircuitState::OPEN && $this->isRecoveryTimeoutExpired($service, $config)) {
|
||||
return CircuitState::HALF_OPEN;
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt detaillierte Metriken für einen Service zurück
|
||||
*/
|
||||
public function getMetrics(string $service): CircuitBreakerMetrics
|
||||
{
|
||||
$state = $this->getState($service);
|
||||
$failureCountItem = $this->cache->get($this->getFailureCountKey($service));
|
||||
$successCountItem = $this->cache->get($this->getSuccessCountKey($service));
|
||||
$halfOpenAttemptsItem = $this->cache->get($this->getHalfOpenAttemptsKey($service));
|
||||
$lastFailureTimeItem = $this->cache->get($this->getLastFailureTimeKey($service));
|
||||
$openedAtItem = $this->cache->get($this->getOpenedAtKey($service));
|
||||
|
||||
return new CircuitBreakerMetrics(
|
||||
state: $state,
|
||||
failureCount: $failureCountItem->isHit ? $failureCountItem->value : 0,
|
||||
successCount: $successCountItem->isHit ? $successCountItem->value : 0,
|
||||
halfOpenAttempts: $halfOpenAttemptsItem->isHit ? $halfOpenAttemptsItem->value : 0,
|
||||
lastFailureTime: $lastFailureTimeItem->isHit
|
||||
? Timestamp::fromFloat($lastFailureTimeItem->value)
|
||||
: null,
|
||||
openedAt: $openedAtItem->isHit
|
||||
? Timestamp::fromFloat($openedAtItem->value)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Circuit Breaker für einen Service zurück
|
||||
*/
|
||||
public function reset(string $service): void
|
||||
{
|
||||
$keys = [
|
||||
$this->getStateKey($service),
|
||||
$this->getFailureCountKey($service),
|
||||
$this->getSuccessCountKey($service),
|
||||
$this->getHalfOpenAttemptsKey($service),
|
||||
$this->getLastFailureTimeKey($service),
|
||||
$this->getOpenedAtKey($service),
|
||||
];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$this->cache->forget($key);
|
||||
}
|
||||
|
||||
$this->log('info', "Circuit breaker for '{$service}' has been reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alle Circuit Breaker zurück
|
||||
*/
|
||||
public function resetAll(): void
|
||||
{
|
||||
// Implementation hängt vom Cache-Backend ab
|
||||
// Hier würde man alle Keys mit dem Prefix löschen
|
||||
$this->log('info', 'All circuit breakers have been reset');
|
||||
}
|
||||
|
||||
private function transitionToOpen(string $service, string $reason = '', ?CircuitBreakerConfig $config = null): void
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$previousState = $this->getState($service, $config);
|
||||
$now = Timestamp::fromClock($this->clock);
|
||||
|
||||
// EMERGENCY: Limit reason string to prevent memory explosion
|
||||
$limitedReason = strlen($reason) > 1000
|
||||
? substr($reason, 0, 1000) . '... (truncated for memory safety)'
|
||||
: $reason;
|
||||
|
||||
$this->cache->set(CacheItem::forSet($this->getStateKey($service), [
|
||||
'state' => CircuitState::OPEN->value,
|
||||
'opened_at' => $now->toFloat(),
|
||||
'reason' => $limitedReason,
|
||||
], $config->metricsRetentionTime));
|
||||
|
||||
$this->cache->set(CacheItem::forSet($this->getOpenedAtKey($service), $now->toFloat(), $config->metricsRetentionTime));
|
||||
$this->cache->set(CacheItem::forSet($this->getLastFailureTimeKey($service), $now->toFloat(), $config->metricsRetentionTime));
|
||||
|
||||
// Publish event
|
||||
if ($this->eventPublisher !== null) {
|
||||
$metrics = $this->getMetrics($service);
|
||||
$event = new CircuitBreakerOpened(
|
||||
service : $service,
|
||||
namespace : $this->cachePrefix,
|
||||
previousState: $previousState,
|
||||
metrics : $metrics,
|
||||
reason : $reason,
|
||||
occurredAt : $now
|
||||
);
|
||||
$this->eventPublisher->publish($event);
|
||||
}
|
||||
}
|
||||
|
||||
private function transitionToHalfOpen(string $service, ?CircuitBreakerConfig $config = null): void
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$previousState = $this->getState($service, $config);
|
||||
$now = Timestamp::fromClock($this->clock);
|
||||
|
||||
$this->cache->set(CacheItem::forSet($this->getStateKey($service), [
|
||||
'state' => CircuitState::HALF_OPEN->value,
|
||||
'transitioned_at' => $now->toFloat(),
|
||||
], $config->metricsRetentionTime));
|
||||
|
||||
// Reset half-open attempts counter
|
||||
$this->cache->forget($this->getHalfOpenAttemptsKey($service));
|
||||
$this->cache->forget($this->getSuccessCountKey($service));
|
||||
|
||||
// Publish event
|
||||
if ($this->eventPublisher !== null) {
|
||||
$metrics = $this->getMetrics($service);
|
||||
$event = new CircuitBreakerHalfOpened(
|
||||
service : $service,
|
||||
namespace : $this->cachePrefix,
|
||||
previousState: $previousState,
|
||||
metrics : $metrics,
|
||||
reason : 'Recovery timeout expired',
|
||||
occurredAt : $now
|
||||
);
|
||||
$this->eventPublisher->publish($event);
|
||||
}
|
||||
}
|
||||
|
||||
private function transitionToClosed(string $service, ?CircuitBreakerConfig $config = null): void
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$previousState = $this->getState($service, $config);
|
||||
$metrics = $this->getMetrics($service);
|
||||
$now = Timestamp::fromClock($this->clock);
|
||||
|
||||
$this->resetMetrics($service);
|
||||
$this->log('info', "Circuit breaker for '{$service}' is now CLOSED");
|
||||
|
||||
// Publish event
|
||||
if ($this->eventPublisher !== null) {
|
||||
$event = new CircuitBreakerClosed(
|
||||
service : $service,
|
||||
namespace : $this->cachePrefix,
|
||||
previousState: $previousState,
|
||||
metrics : $metrics,
|
||||
reason : 'Sufficient successful attempts',
|
||||
occurredAt : $now
|
||||
);
|
||||
$this->eventPublisher->publish($event);
|
||||
}
|
||||
}
|
||||
|
||||
private function incrementFailureCount(string $service, ?CircuitBreakerConfig $config = null): int
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$key = $this->getFailureCountKey($service);
|
||||
$cacheItem = $this->cache->get($key);
|
||||
|
||||
$count = ($cacheItem->isHit ? $cacheItem->value : 0) + 1;
|
||||
|
||||
$this->cache->set(CacheItem::forSet($key, $count, $config->metricsRetentionTime));
|
||||
|
||||
// Update last failure time
|
||||
$now = Timestamp::fromClock($this->clock);
|
||||
$this->cache->set(CacheItem::forSet($this->getLastFailureTimeKey($service), $now->toFloat(), $config->metricsRetentionTime));
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function incrementSuccessCount(string $service, ?CircuitBreakerConfig $config = null): int
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$key = $this->getSuccessCountKey($service);
|
||||
$cacheItem = $this->cache->get($key);
|
||||
|
||||
$count = ($cacheItem->isHit ? $cacheItem->value : 0) + 1;
|
||||
$this->cache->set(CacheItem::forSet($key, $count, $config->metricsRetentionTime));
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function incrementHalfOpenAttempts(string $service, ?CircuitBreakerConfig $config = null): int
|
||||
{
|
||||
$config ??= $this->getDefaultConfig();
|
||||
$key = $this->getHalfOpenAttemptsKey($service);
|
||||
$cacheItem = $this->cache->get($key);
|
||||
|
||||
$count = ($cacheItem->isHit ? $cacheItem->value : 0) + 1;
|
||||
$this->cache->set(CacheItem::forSet($key, $count, $config->metricsRetentionTime));
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function getHalfOpenAttempts(string $service): int
|
||||
{
|
||||
$cacheItem = $this->cache->get($this->getHalfOpenAttemptsKey($service));
|
||||
if (! $cacheItem->isHit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ensure we return an integer, handle case where value is false or invalid
|
||||
$value = $cacheItem->value;
|
||||
|
||||
return is_int($value) ? $value : 0;
|
||||
}
|
||||
|
||||
private function isRecoveryTimeoutExpired(string $service, CircuitBreakerConfig $config): bool
|
||||
{
|
||||
$metrics = $this->getMetrics($service);
|
||||
|
||||
return $metrics->hasRecoveryTimeoutExpired($config->recoveryTimeout);
|
||||
}
|
||||
|
||||
private function calculateRetryAfter(string $service, CircuitBreakerConfig $config): int
|
||||
{
|
||||
$metrics = $this->getMetrics($service);
|
||||
|
||||
return $metrics->getRetryAfterDuration($config->recoveryTimeout)->toCacheSeconds();
|
||||
}
|
||||
|
||||
private function resetMetrics(string $service): void
|
||||
{
|
||||
$this->cache->forget($this->getStateKey($service));
|
||||
$this->cache->forget($this->getFailureCountKey($service));
|
||||
$this->cache->forget($this->getSuccessCountKey($service));
|
||||
$this->cache->forget($this->getHalfOpenAttemptsKey($service));
|
||||
}
|
||||
|
||||
private function getDefaultConfig(): CircuitBreakerConfig
|
||||
{
|
||||
static $defaultConfig = null;
|
||||
|
||||
if ($defaultConfig === null) {
|
||||
$defaultConfig = new CircuitBreakerConfig(
|
||||
failureThreshold: self::DEFAULT_FAILURE_THRESHOLD,
|
||||
recoveryTimeout: Duration::fromSeconds(self::DEFAULT_RECOVERY_TIMEOUT),
|
||||
halfOpenMaxAttempts: self::DEFAULT_HALF_OPEN_MAX_ATTEMPTS,
|
||||
successThreshold: self::DEFAULT_SUCCESS_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
return $defaultConfig;
|
||||
}
|
||||
|
||||
// Cache Keys
|
||||
private function getStateKey(string $service): CacheKey
|
||||
{
|
||||
return CacheKey::fromString($this->cachePrefix . $service . ':state');
|
||||
}
|
||||
|
||||
private function getFailureCountKey(string $service): CacheKey
|
||||
{
|
||||
return CacheKey::fromString($this->cachePrefix . $service . ':failures');
|
||||
}
|
||||
|
||||
private function getSuccessCountKey(string $service): CacheKey
|
||||
{
|
||||
return CacheKey::fromString($this->cachePrefix . $service . ':successes');
|
||||
}
|
||||
|
||||
private function getHalfOpenAttemptsKey(string $service): CacheKey
|
||||
{
|
||||
return CacheKey::fromString($this->cachePrefix . $service . ':half_open_attempts');
|
||||
}
|
||||
|
||||
private function getLastFailureTimeKey(string $service): CacheKey
|
||||
{
|
||||
return CacheKey::fromString($this->cachePrefix . $service . ':last_failure');
|
||||
}
|
||||
|
||||
private function getOpenedAtKey(string $service): CacheKey
|
||||
{
|
||||
return CacheKey::fromString($this->cachePrefix . $service . ':opened_at');
|
||||
}
|
||||
|
||||
private function registerService(string $service): void
|
||||
{
|
||||
if ($this->serviceRegistry !== null) {
|
||||
$this->serviceRegistry->registerService($service, $this->namespace);
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $level, string $message): void
|
||||
{
|
||||
if ($this->logger !== null) {
|
||||
match($level) {
|
||||
'debug' => $this->logger->debug($message),
|
||||
'info' => $this->logger->info($message),
|
||||
'warning' => $this->logger->warning($message),
|
||||
'error' => $this->logger->error($message),
|
||||
default => $this->logger->info($message),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src/Framework/CircuitBreaker/CircuitBreakerConfig.php
Normal file
224
src/Framework/CircuitBreaker/CircuitBreakerConfig.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\CircuitBreaker\FailurePredicate\FailurePredicate;
|
||||
use App\Framework\CircuitBreaker\FailurePredicate\FailurePredicateFactory;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* Konfiguration für Circuit Breaker
|
||||
*/
|
||||
final readonly class CircuitBreakerConfig
|
||||
{
|
||||
public Duration $recoveryTimeout;
|
||||
|
||||
public Duration $metricsRetentionTime;
|
||||
|
||||
public Duration $slidingWindowSize;
|
||||
|
||||
public function __construct(
|
||||
/**
|
||||
* Anzahl der Fehler bevor der Circuit Breaker öffnet
|
||||
*/
|
||||
public int $failureThreshold = 5,
|
||||
|
||||
/**
|
||||
* Zeit bis der Circuit Breaker von OPEN zu HALF_OPEN wechselt
|
||||
*/
|
||||
?Duration $recoveryTimeout = null,
|
||||
|
||||
/**
|
||||
* Maximale Anzahl von Versuchen im HALF_OPEN State
|
||||
*/
|
||||
public int $halfOpenMaxAttempts = 3,
|
||||
|
||||
/**
|
||||
* Anzahl erfolgreicher Aufrufe im HALF_OPEN State um zu CLOSED zu wechseln
|
||||
*/
|
||||
public int $successThreshold = 3,
|
||||
|
||||
/**
|
||||
* Zeitfenster für Metriken-Aufbewahrung
|
||||
*/
|
||||
?Duration $metricsRetentionTime = null,
|
||||
|
||||
/**
|
||||
* Sliding Window Größe für Failure Rate Berechnung
|
||||
*/
|
||||
?Duration $slidingWindowSize = null,
|
||||
|
||||
/**
|
||||
* Failure predicate für erweiterte Fehlerbehandlung
|
||||
*/
|
||||
public ?FailurePredicate $failurePredicate = null,
|
||||
) {
|
||||
// Set defaults for nullable Duration fields
|
||||
$this->recoveryTimeout = $recoveryTimeout ?? Duration::fromSeconds(60);
|
||||
$this->metricsRetentionTime = $metricsRetentionTime ?? Duration::fromHours(1);
|
||||
$this->slidingWindowSize = $slidingWindowSize ?? Duration::fromMinutes(5);
|
||||
|
||||
if ($this->failureThreshold < 1) {
|
||||
throw new \InvalidArgumentException('Failure threshold must be at least 1');
|
||||
}
|
||||
|
||||
if ($this->recoveryTimeout->isZero()) {
|
||||
throw new \InvalidArgumentException('Recovery timeout must be greater than zero');
|
||||
}
|
||||
|
||||
if ($this->halfOpenMaxAttempts < 1) {
|
||||
throw new \InvalidArgumentException('Half-open max attempts must be at least 1');
|
||||
}
|
||||
|
||||
if ($this->successThreshold < 1) {
|
||||
throw new \InvalidArgumentException('Success threshold must be at least 1');
|
||||
}
|
||||
|
||||
if ($this->metricsRetentionTime->isZero()) {
|
||||
throw new \InvalidArgumentException('Metrics retention time must be greater than zero');
|
||||
}
|
||||
|
||||
if ($this->slidingWindowSize->isZero()) {
|
||||
throw new \InvalidArgumentException('Sliding window size must be greater than zero');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob eine Exception den Circuit Breaker triggern soll
|
||||
*/
|
||||
public function shouldTriggerOnException(\Throwable $exception, string $service = 'default'): bool
|
||||
{
|
||||
if ($this->failurePredicate === null) {
|
||||
// Default behavior: trigger on all exceptions
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->failurePredicate->shouldTrigger($exception, $service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Konfiguration für externe Services
|
||||
*/
|
||||
public static function forExternalService(
|
||||
int $failureThreshold = 5,
|
||||
?Duration $recoveryTimeout = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout ?? Duration::fromMinutes(5),
|
||||
halfOpenMaxAttempts: 3,
|
||||
successThreshold: 2,
|
||||
metricsRetentionTime: Duration::fromHours(2),
|
||||
slidingWindowSize: Duration::fromMinutes(10),
|
||||
failurePredicate: FailurePredicateFactory::forExternalService()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Konfiguration für Datenbank-Verbindungen
|
||||
*/
|
||||
public static function forDatabase(
|
||||
int $failureThreshold = 3,
|
||||
?Duration $recoveryTimeout = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout ?? Duration::fromSeconds(30),
|
||||
halfOpenMaxAttempts: 2,
|
||||
successThreshold: 2,
|
||||
metricsRetentionTime: Duration::fromMinutes(30),
|
||||
slidingWindowSize: Duration::fromMinutes(2),
|
||||
failurePredicate: FailurePredicateFactory::forDatabase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine strenge Konfiguration für kritische Services
|
||||
*/
|
||||
public static function strict(
|
||||
int $failureThreshold = 3,
|
||||
?Duration $recoveryTimeout = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout ?? Duration::fromMinutes(10),
|
||||
halfOpenMaxAttempts: 1,
|
||||
successThreshold: 5,
|
||||
metricsRetentionTime: Duration::fromHours(4),
|
||||
slidingWindowSize: Duration::fromMinutes(15),
|
||||
failurePredicate: FailurePredicateFactory::criticalErrorsOnly()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Komfortmethode für Duration-basierte Konfiguration
|
||||
*/
|
||||
public static function withDurations(
|
||||
int $failureThreshold,
|
||||
Duration $recoveryTimeout,
|
||||
Duration $slidingWindow,
|
||||
?Duration $metricsRetention = null,
|
||||
?FailurePredicate $failurePredicate = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout,
|
||||
metricsRetentionTime: $metricsRetention ?? $recoveryTimeout->multiply(2),
|
||||
slidingWindowSize: $slidingWindow,
|
||||
failurePredicate: $failurePredicate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Konfiguration mit custom FailurePredicate
|
||||
*/
|
||||
public static function withFailurePredicate(
|
||||
FailurePredicate $failurePredicate,
|
||||
int $failureThreshold = 5,
|
||||
?Duration $recoveryTimeout = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout ?? Duration::fromMinutes(2),
|
||||
failurePredicate: $failurePredicate
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Konfiguration für HTTP Client
|
||||
*/
|
||||
public static function forHttpClient(
|
||||
int $failureThreshold = 5,
|
||||
?Duration $recoveryTimeout = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout ?? Duration::fromMinutes(3),
|
||||
halfOpenMaxAttempts: 2,
|
||||
successThreshold: 2,
|
||||
metricsRetentionTime: Duration::fromHours(1),
|
||||
slidingWindowSize: Duration::fromMinutes(5),
|
||||
failurePredicate: FailurePredicateFactory::forHttpClient()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Konfiguration die nur Timeout-Fehler behandelt
|
||||
*/
|
||||
public static function forTimeoutErrors(
|
||||
int $failureThreshold = 3,
|
||||
?Duration $recoveryTimeout = null
|
||||
): self {
|
||||
return new self(
|
||||
failureThreshold: $failureThreshold,
|
||||
recoveryTimeout: $recoveryTimeout ?? Duration::fromMinutes(1),
|
||||
halfOpenMaxAttempts: 2,
|
||||
successThreshold: 2,
|
||||
metricsRetentionTime: Duration::fromMinutes(30),
|
||||
slidingWindowSize: Duration::fromMinutes(2),
|
||||
failurePredicate: FailurePredicateFactory::timeoutErrors()
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/Framework/CircuitBreaker/CircuitBreakerException.php
Normal file
54
src/Framework/CircuitBreaker/CircuitBreakerException.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
/**
|
||||
* Exception thrown when Circuit Breaker is in OPEN state
|
||||
*/
|
||||
final class CircuitBreakerException extends FrameworkException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $service,
|
||||
public readonly CircuitState $state,
|
||||
public readonly int $failureCount,
|
||||
public readonly int $retryAfterSeconds,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$message = "Circuit breaker for service '{$service}' is {$state->value}. Service unavailable after {$failureCount} failures.";
|
||||
|
||||
$context = new ExceptionContext(
|
||||
operation: 'circuit_breaker_check',
|
||||
component: 'CircuitBreaker',
|
||||
data: [
|
||||
'service' => $service,
|
||||
'state' => $state->value,
|
||||
'failure_count' => $failureCount,
|
||||
'retry_after_seconds' => $retryAfterSeconds,
|
||||
],
|
||||
metadata: [
|
||||
'requires_alert' => true,
|
||||
'recoverable' => true,
|
||||
'error_code' => ErrorCode::SERVICE_CIRCUIT_OPEN->value,
|
||||
'http_status' => 503,
|
||||
'additional_headers' => [
|
||||
'Retry-After' => (string) $retryAfterSeconds,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
context: $context,
|
||||
code: 503,
|
||||
previous: $previous,
|
||||
errorCode: ErrorCode::SERVICE_CIRCUIT_OPEN,
|
||||
retryAfter: $retryAfterSeconds
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/Framework/CircuitBreaker/CircuitBreakerInitializer.php
Normal file
74
src/Framework/CircuitBreaker/CircuitBreakerInitializer.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\Driver\NullCache;
|
||||
use App\Framework\Cache\GeneralCache;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Serializer\Json\JsonSerializer;
|
||||
|
||||
/**
|
||||
* Initializer für Circuit Breaker Services
|
||||
*/
|
||||
final readonly class CircuitBreakerInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initializeCircuitBreaker(Container $container): void
|
||||
{
|
||||
// Circuit Breaker Manager with EMERGENCY NullCache to prevent memory issues
|
||||
$container->bind(CircuitBreakerManager::class, function (Container $container) {
|
||||
// Create a GeneralCache with NullCache driver to implement Cache interface
|
||||
$nullCache = new GeneralCache(
|
||||
adapter: new NullCache(),
|
||||
serializer: new JsonSerializer()
|
||||
);
|
||||
|
||||
return CircuitBreakerManager::withDefaults(
|
||||
cache: $nullCache, // EMERGENCY: Use NullCache wrapped in GeneralCache
|
||||
clock: $container->get(Clock::class),
|
||||
logger: $container->get(Logger::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Standard Circuit Breaker
|
||||
$container->bind(CircuitBreaker::class, function (Container $container) {
|
||||
return $container->get(CircuitBreakerManager::class)->getCircuitBreaker();
|
||||
});
|
||||
|
||||
// Database Circuit Breaker
|
||||
$container->bind(DatabaseCircuitBreaker::class, function (Container $container) {
|
||||
return new DatabaseCircuitBreaker(
|
||||
circuitBreaker: $container->get(CircuitBreaker::class),
|
||||
config: CircuitBreakerConfig::forDatabase()
|
||||
);
|
||||
});
|
||||
|
||||
// HTTP Client Circuit Breaker
|
||||
$container->bind(HttpClientCircuitBreaker::class, function (Container $container) {
|
||||
return new HttpClientCircuitBreaker(
|
||||
circuitBreaker: $container->get(CircuitBreaker::class),
|
||||
httpClient: $container->get(HttpClient::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Circuit Breaker Middlewares
|
||||
$container->bind('circuit_breaker_middleware_api', function (Container $container) {
|
||||
return CircuitBreakerMiddleware::forApi(
|
||||
$container->get(CircuitBreaker::class)
|
||||
);
|
||||
});
|
||||
|
||||
$container->bind('circuit_breaker_middleware_admin', function (Container $container) {
|
||||
return CircuitBreakerMiddleware::forAdmin(
|
||||
$container->get(CircuitBreaker::class)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
23
src/Framework/CircuitBreaker/CircuitBreakerInterface.php
Normal file
23
src/Framework/CircuitBreaker/CircuitBreakerInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
/**
|
||||
* Interface for Circuit Breaker implementations
|
||||
*
|
||||
* Provides a contract for circuit breaker functionality
|
||||
* that can be implemented by different circuit breaker implementations
|
||||
* or test doubles.
|
||||
*/
|
||||
interface CircuitBreakerInterface
|
||||
{
|
||||
/**
|
||||
* Get metrics for a service
|
||||
*
|
||||
* @param string $service Service name
|
||||
* @return CircuitBreakerMetrics Service metrics
|
||||
*/
|
||||
public function getMetrics(string $service): CircuitBreakerMetrics;
|
||||
}
|
||||
308
src/Framework/CircuitBreaker/CircuitBreakerManager.php
Normal file
308
src/Framework/CircuitBreaker/CircuitBreakerManager.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\CircuitBreaker\Registry\ServiceRegistry;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Manager für Circuit Breaker Monitoring und Management
|
||||
*/
|
||||
final class CircuitBreakerManager
|
||||
{
|
||||
/**
|
||||
* @var array<string, CircuitBreaker>
|
||||
*/
|
||||
private array $circuitBreakers;
|
||||
|
||||
/**
|
||||
* @var array<string, CircuitBreakerConfig>
|
||||
*/
|
||||
private array $configurations;
|
||||
|
||||
public function __construct(
|
||||
private readonly Cache $cache,
|
||||
private readonly Clock $clock,
|
||||
private readonly ?Logger $logger = null,
|
||||
private readonly ?ServiceRegistry $serviceRegistry = null,
|
||||
array $configurations = []
|
||||
) {
|
||||
$this->configurations = $configurations;
|
||||
$this->circuitBreakers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt oder erstellt einen Circuit Breaker für einen Service
|
||||
*/
|
||||
public function getCircuitBreaker(string $namespace = 'default'): CircuitBreaker
|
||||
{
|
||||
if (! isset($this->circuitBreakers[$namespace])) {
|
||||
$this->circuitBreakers[$namespace] = new CircuitBreaker(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
logger: $this->logger,
|
||||
eventPublisher: null, // TODO: Add event publisher injection
|
||||
serviceRegistry: $this->serviceRegistry,
|
||||
namespace: $namespace
|
||||
);
|
||||
}
|
||||
|
||||
return $this->circuitBreakers[$namespace];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuellen Status aller Services zurück
|
||||
*/
|
||||
public function getAllServicesStatus(): array
|
||||
{
|
||||
$status = [];
|
||||
|
||||
foreach ($this->circuitBreakers as $namespace => $circuitBreaker) {
|
||||
$status[$namespace] = [];
|
||||
|
||||
// Alle Services in diesem Namespace finden
|
||||
$services = $this->getServicesInNamespace($namespace);
|
||||
|
||||
foreach ($services as $service) {
|
||||
$metrics = $circuitBreaker->getMetrics($service);
|
||||
$status[$namespace][$service] = [
|
||||
'state' => $metrics->state->value,
|
||||
'failure_count' => $metrics->failureCount,
|
||||
'success_count' => $metrics->successCount,
|
||||
'half_open_attempts' => $metrics->halfOpenAttempts,
|
||||
'last_failure_time' => $metrics->lastFailureTime?->toTimestamp(),
|
||||
'opened_at' => $metrics->openedAt?->toTimestamp(),
|
||||
'health_status' => $this->determineHealthStatus($metrics),
|
||||
'configuration' => $this->getServiceConfiguration($service),
|
||||
'summary' => $metrics->getSummary(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt aggregierte Statistiken zurück
|
||||
*/
|
||||
public function getGlobalStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_services' => 0,
|
||||
'healthy_services' => 0,
|
||||
'degraded_services' => 0,
|
||||
'failed_services' => 0,
|
||||
'circuit_breakers_open' => 0,
|
||||
'circuit_breakers_half_open' => 0,
|
||||
'circuit_breakers_closed' => 0,
|
||||
];
|
||||
|
||||
$allStatus = $this->getAllServicesStatus();
|
||||
|
||||
foreach ($allStatus as $namespace => $services) {
|
||||
foreach ($services as $service => $serviceStatus) {
|
||||
$stats['total_services']++;
|
||||
|
||||
match ($serviceStatus['state']) {
|
||||
'closed' => $stats['circuit_breakers_closed']++,
|
||||
'open' => $stats['circuit_breakers_open']++,
|
||||
'half_open' => $stats['circuit_breakers_half_open']++,
|
||||
};
|
||||
|
||||
match ($serviceStatus['health_status']) {
|
||||
'healthy' => $stats['healthy_services']++,
|
||||
'degraded' => $stats['degraded_services']++,
|
||||
'failed' => $stats['failed_services']++,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alle Circuit Breaker zurück
|
||||
*/
|
||||
public function resetAll(): void
|
||||
{
|
||||
foreach ($this->circuitBreakers as $circuitBreaker) {
|
||||
$circuitBreaker->resetAll();
|
||||
}
|
||||
|
||||
if ($this->logger) {
|
||||
$this->logger->info('All circuit breakers have been reset by CircuitBreakerManager');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen spezifischen Service zurück
|
||||
*/
|
||||
public function resetService(string $service, string $namespace = 'default'): void
|
||||
{
|
||||
$circuitBreaker = $this->getCircuitBreaker($namespace);
|
||||
$circuitBreaker->reset($service);
|
||||
|
||||
if ($this->logger) {
|
||||
$this->logger->info("Circuit breaker for service '{$service}' in namespace '{$namespace}' has been reset");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Health Checks für alle konfigurierten Services durch
|
||||
*/
|
||||
public function performHealthChecks(): array
|
||||
{
|
||||
$results = [];
|
||||
$allStatus = $this->getAllServicesStatus();
|
||||
|
||||
foreach ($allStatus as $namespace => $services) {
|
||||
$results[$namespace] = [];
|
||||
|
||||
foreach ($services as $service => $serviceStatus) {
|
||||
$healthCheckResult = $this->performServiceHealthCheck($service, $namespace);
|
||||
$results[$namespace][$service] = $healthCheckResult;
|
||||
|
||||
// Logging für kritische Zustände
|
||||
if ($healthCheckResult['status'] === 'failed' && $serviceStatus['state'] === 'open') {
|
||||
if ($this->logger) {
|
||||
$this->logger->critical(
|
||||
"Service '{$service}' in namespace '{$namespace}' is failing and circuit breaker is open",
|
||||
$healthCheckResult
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert Konfiguration für Monitoring-Tools
|
||||
*/
|
||||
public function exportConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'circuit_breakers' => array_keys($this->circuitBreakers),
|
||||
'configurations' => $this->configurations,
|
||||
'timestamp' => $this->clock->now()->format('c'),
|
||||
'version' => '1.0',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Importiert Konfiguration
|
||||
*/
|
||||
public function importConfiguration(array $config): void
|
||||
{
|
||||
if (isset($config['configurations'])) {
|
||||
foreach ($config['configurations'] as $service => $serviceConfig) {
|
||||
$this->configurations[$service] = new CircuitBreakerConfig(...$serviceConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet alle Services in einem Namespace
|
||||
*/
|
||||
private function getServicesInNamespace(string $namespace): array
|
||||
{
|
||||
if ($this->serviceRegistry !== null) {
|
||||
return $this->serviceRegistry->discoverServices($namespace);
|
||||
}
|
||||
|
||||
// Fallback: Returniere die konfigurierten Services
|
||||
$services = [];
|
||||
foreach ($this->configurations as $service => $config) {
|
||||
$services[] = $service;
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt den Health Status basierend auf Metriken
|
||||
*/
|
||||
private function determineHealthStatus(CircuitBreakerMetrics $metrics): string
|
||||
{
|
||||
return match ($metrics->state) {
|
||||
CircuitState::CLOSED => 'healthy',
|
||||
CircuitState::HALF_OPEN => 'degraded',
|
||||
CircuitState::OPEN => 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die Konfiguration für einen Service
|
||||
*/
|
||||
private function getServiceConfiguration(string $service): ?array
|
||||
{
|
||||
$config = $this->configurations[$service] ?? null;
|
||||
|
||||
if ($config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'failure_threshold' => $config->failureThreshold,
|
||||
'recovery_timeout' => $config->recoveryTimeout->toHumanReadable(),
|
||||
'half_open_max_attempts' => $config->halfOpenMaxAttempts,
|
||||
'success_threshold' => $config->successThreshold,
|
||||
'metrics_retention_time' => $config->metricsRetentionTime->toHumanReadable(),
|
||||
'sliding_window_size' => $config->slidingWindowSize->toHumanReadable(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Health Check für einen Service durch
|
||||
*/
|
||||
private function performServiceHealthCheck(string $service, string $namespace): array
|
||||
{
|
||||
$circuitBreaker = $this->getCircuitBreaker($namespace);
|
||||
$metrics = $circuitBreaker->getMetrics($service);
|
||||
|
||||
$healthCheck = [
|
||||
'service' => $service,
|
||||
'namespace' => $namespace,
|
||||
'status' => $this->determineHealthStatus($metrics),
|
||||
'checked_at' => $this->clock->now()->format('c'),
|
||||
'metrics' => $metrics->toArray(),
|
||||
'summary' => $metrics->getSummary(),
|
||||
];
|
||||
|
||||
// Zusätzliche Health Check Logik hier implementieren
|
||||
// z.B. ping external service, check database connection etc.
|
||||
|
||||
return $healthCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für Standard-Setup
|
||||
*/
|
||||
public static function withDefaults(
|
||||
Cache $cache,
|
||||
Clock $clock,
|
||||
?Logger $logger = null,
|
||||
?ServiceRegistry $serviceRegistry = null
|
||||
): self {
|
||||
$configs = [
|
||||
'database' => CircuitBreakerConfig::forDatabase(),
|
||||
'api' => new CircuitBreakerConfig(
|
||||
failureThreshold: 5,
|
||||
recoveryTimeout: Duration::fromMinutes(1),
|
||||
halfOpenMaxAttempts: 3,
|
||||
successThreshold: 2,
|
||||
metricsRetentionTime: Duration::fromHours(1),
|
||||
slidingWindowSize: Duration::fromMinutes(5)
|
||||
),
|
||||
'external_service' => CircuitBreakerConfig::forExternalService(),
|
||||
];
|
||||
|
||||
return new self($cache, $clock, $logger, $serviceRegistry, $configs);
|
||||
}
|
||||
}
|
||||
172
src/Framework/CircuitBreaker/CircuitBreakerMetrics.php
Normal file
172
src/Framework/CircuitBreaker/CircuitBreakerMetrics.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Value Object für Circuit Breaker Metriken
|
||||
*
|
||||
* Immutable Datenstruktur die alle relevanten Metriken
|
||||
* eines Circuit Breakers enthält.
|
||||
*/
|
||||
final readonly class CircuitBreakerMetrics
|
||||
{
|
||||
public function __construct(
|
||||
public CircuitState $state,
|
||||
public int $failureCount,
|
||||
public int $successCount,
|
||||
public int $halfOpenAttempts,
|
||||
public ?Timestamp $lastFailureTime,
|
||||
public ?Timestamp $openedAt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Circuit Breaker verfügbar ist (CLOSED oder HALF_OPEN)
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->state !== CircuitState::OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Circuit Breaker blockiert (OPEN)
|
||||
*/
|
||||
public function isBlocked(): bool
|
||||
{
|
||||
return $this->state === CircuitState::OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob sich der Circuit Breaker in der Test-Phase befindet
|
||||
*/
|
||||
public function isInTestPhase(): bool
|
||||
{
|
||||
return $this->state === CircuitState::HALF_OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Dauer seit dem Öffnen
|
||||
*/
|
||||
public function getUptimeDuration(): ?Duration
|
||||
{
|
||||
if ($this->openedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Timestamp::now();
|
||||
|
||||
return $now->diff($this->openedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Dauer seit dem letzten Fehler
|
||||
*/
|
||||
public function getTimeSinceLastFailure(): ?Duration
|
||||
{
|
||||
if ($this->lastFailureTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Timestamp::now();
|
||||
|
||||
return $now->diff($this->lastFailureTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob genug Zeit seit dem letzten Fehler vergangen ist
|
||||
*/
|
||||
public function hasRecoveryTimeoutExpired(Duration $recoveryTimeout): bool
|
||||
{
|
||||
if ($this->openedAt === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$uptime = $this->getUptimeDuration();
|
||||
|
||||
return $uptime !== null && $uptime->greaterThan($recoveryTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Zeit bis zum nächsten Retry-Versuch
|
||||
*/
|
||||
public function getRetryAfterDuration(Duration $recoveryTimeout): Duration
|
||||
{
|
||||
if ($this->openedAt === null) {
|
||||
return Duration::zero();
|
||||
}
|
||||
|
||||
$uptime = $this->getUptimeDuration();
|
||||
if ($uptime === null || $uptime->greaterThan($recoveryTimeout)) {
|
||||
return Duration::zero();
|
||||
}
|
||||
|
||||
try {
|
||||
return $recoveryTimeout->subtract($uptime);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return Duration::zero();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine zusammenfassende Beschreibung des aktuellen Zustands zurück
|
||||
*/
|
||||
public function getSummary(): string
|
||||
{
|
||||
return match ($this->state) {
|
||||
CircuitState::CLOSED => sprintf(
|
||||
'CLOSED - %d failures recorded',
|
||||
$this->failureCount
|
||||
),
|
||||
CircuitState::OPEN => sprintf(
|
||||
'OPEN - %d failures, opened %s ago',
|
||||
$this->failureCount,
|
||||
$this->getUptimeDuration()?->toHumanReadable() ?? 'unknown'
|
||||
),
|
||||
CircuitState::HALF_OPEN => sprintf(
|
||||
'HALF_OPEN - %d/%d test attempts, %d successes',
|
||||
$this->halfOpenAttempts,
|
||||
$this->halfOpenAttempts + $this->successCount,
|
||||
$this->successCount
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array-Format (für Backward-Compatibility)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state->value,
|
||||
'failure_count' => $this->failureCount,
|
||||
'success_count' => $this->successCount,
|
||||
'half_open_attempts' => $this->halfOpenAttempts,
|
||||
'last_failure_time' => $this->lastFailureTime?->toTimestamp(),
|
||||
'opened_at' => $this->openedAt?->toTimestamp(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Metriken aus Array-Daten (für Migration)
|
||||
*/
|
||||
public static function fromArray(CircuitState $state, array $data): self
|
||||
{
|
||||
return new self(
|
||||
state: $state,
|
||||
failureCount: $data['failure_count'] ?? 0,
|
||||
successCount: $data['success_count'] ?? 0,
|
||||
halfOpenAttempts: $data['half_open_attempts'] ?? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/Framework/CircuitBreaker/CircuitBreakerMiddleware.php
Normal file
165
src/Framework/CircuitBreaker/CircuitBreakerMiddleware.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Middleware zur Integration von Circuit Breaker Pattern in HTTP-Requests
|
||||
*/
|
||||
final readonly class CircuitBreakerMiddleware implements HttpMiddleware
|
||||
{
|
||||
/**
|
||||
* Services die durch Circuit Breaker geschützt werden sollen
|
||||
* @var array<string, CircuitBreakerConfig>
|
||||
*/
|
||||
private array $protectedServices;
|
||||
|
||||
public function __construct(
|
||||
private CircuitBreaker $circuitBreaker,
|
||||
array $protectedServices = []
|
||||
) {
|
||||
$this->protectedServices = $protectedServices;
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$request = $context->request;
|
||||
$route = $stateManager->get('route_name') ?? $request->path;
|
||||
|
||||
// Prüfen ob Route durch Circuit Breaker geschützt werden soll
|
||||
$service = $this->getServiceForRoute($route);
|
||||
if ($service === null) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
$config = $this->protectedServices[$service] ?? null;
|
||||
|
||||
try {
|
||||
// Circuit Breaker prüfen bevor Request verarbeitet wird
|
||||
$this->circuitBreaker->check($service, $config);
|
||||
|
||||
// Request normal verarbeiten
|
||||
$resultContext = $next($context);
|
||||
|
||||
// Erfolg nur bei 2xx Status Codes prüfen wenn Response vorhanden
|
||||
if ($resultContext->hasResponse()) {
|
||||
$response = $resultContext->response;
|
||||
$statusCode = $response?->status->value;
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
$this->circuitBreaker->recordSuccess($service, $config);
|
||||
} elseif ($statusCode >= 500) {
|
||||
// 5xx Fehler als Circuit Breaker Fehler behandeln
|
||||
$exception = new \Exception("HTTP {$statusCode} response");
|
||||
$this->circuitBreaker->recordFailure($service, $exception, $config);
|
||||
}
|
||||
}
|
||||
|
||||
return $resultContext;
|
||||
|
||||
} catch (CircuitBreakerException $e) {
|
||||
// Circuit Breaker ist offen - Service Unavailable Response
|
||||
$response = $this->createCircuitOpenResponse($e);
|
||||
|
||||
return $context->withResponse($response);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// Andere Exceptions als Circuit Breaker Fehler behandeln
|
||||
$this->circuitBreaker->recordFailure($service, $e, $config);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt welcher Service für eine Route zuständig ist
|
||||
*/
|
||||
private function getServiceForRoute(string $route): ?string
|
||||
{
|
||||
// Einfache Route-zu-Service Mapping
|
||||
// Kann durch komplexere Logik ersetzt werden
|
||||
|
||||
if (str_starts_with($route, '/api/')) {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
if (str_starts_with($route, '/admin/')) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
// Weitere Service-Mappings...
|
||||
foreach ($this->protectedServices as $service => $config) {
|
||||
if (str_contains($route, $service)) {
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Response wenn Circuit Breaker offen ist
|
||||
*/
|
||||
private function createCircuitOpenResponse(CircuitBreakerException $e): HttpResponse
|
||||
{
|
||||
$body = json_encode([
|
||||
'error' => true,
|
||||
'code' => 503,
|
||||
'message' => 'Service temporarily unavailable. Please try again later.',
|
||||
'service' => $e->service,
|
||||
'retryAfter' => $e->retryAfterSeconds,
|
||||
'timestamp' => date('c'),
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
return new HttpResponse(
|
||||
status: Status::SERVICE_UNAVAILABLE,
|
||||
headers: new Headers(
|
||||
[
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'Retry-After' => (string) $e->retryAfterSeconds,
|
||||
'X-Circuit-Breaker' => 'open',
|
||||
'X-Service' => $e->service,
|
||||
],
|
||||
),
|
||||
body: $body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method für Standard-API-Schutz
|
||||
*/
|
||||
public static function forApi(CircuitBreaker $circuitBreaker): self
|
||||
{
|
||||
return new self($circuitBreaker, [
|
||||
'api' => CircuitBreakerConfig::forExternalService(
|
||||
failureThreshold: 10,
|
||||
recoveryTimeout: Duration::fromSeconds(30)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method für Admin-Bereich-Schutz
|
||||
*/
|
||||
public static function forAdmin(CircuitBreaker $circuitBreaker): self
|
||||
{
|
||||
return new self($circuitBreaker, [
|
||||
'admin' => new CircuitBreakerConfig(
|
||||
failureThreshold: 5,
|
||||
recoveryTimeout: Duration::fromSeconds(60)
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
src/Framework/CircuitBreaker/CircuitState.php
Normal file
19
src/Framework/CircuitBreaker/CircuitState.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
/**
|
||||
* Circuit Breaker States nach dem Circuit Breaker Pattern
|
||||
*
|
||||
* CLOSED: Normal operation, requests allowed
|
||||
* OPEN: Failures exceeded threshold, requests blocked
|
||||
* HALF_OPEN: Testing if service recovered
|
||||
*/
|
||||
enum CircuitState: string
|
||||
{
|
||||
case CLOSED = 'closed';
|
||||
case OPEN = 'open';
|
||||
case HALF_OPEN = 'half_open';
|
||||
}
|
||||
248
src/Framework/CircuitBreaker/Commands/CircuitBreakerCommand.php
Normal file
248
src/Framework/CircuitBreaker/Commands/CircuitBreakerCommand.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Commands;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerManager;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\ExitCode;
|
||||
|
||||
/**
|
||||
* Console Command für Circuit Breaker Management
|
||||
*/
|
||||
final readonly class CircuitBreakerCommand
|
||||
{
|
||||
public function __construct(
|
||||
private CircuitBreakerManager $circuitBreakerManager
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'circuit-breaker',
|
||||
description: 'Manage circuit breakers - status, reset, health checks'
|
||||
)]
|
||||
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$command = $args[0] ?? 'status';
|
||||
|
||||
return match ($command) {
|
||||
'status' => $this->handleStatus($input, $output),
|
||||
'stats' => $this->handleStats($input, $output),
|
||||
'health' => $this->handleHealth($input, $output),
|
||||
'reset' => $this->handleReset($input, $output),
|
||||
'config' => $this->handleConfig($input, $output),
|
||||
'export' => $this->handleExport($input, $output),
|
||||
default => $this->handleStatus($input, $output),
|
||||
};
|
||||
}
|
||||
|
||||
private function handleStatus(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$specificService = $args[1] ?? null;
|
||||
|
||||
$allStatus = $this->circuitBreakerManager->getAllServicesStatus();
|
||||
|
||||
if (empty($allStatus)) {
|
||||
$output->writeLine('<info>No circuit breakers configured</info>');
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeLine('<info>Circuit Breaker Status</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
foreach ($allStatus as $namespace => $services) {
|
||||
if (empty($services)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeLine("<comment>Namespace: {$namespace}</comment>");
|
||||
$output->writeLine(str_repeat('-', 50));
|
||||
|
||||
foreach ($services as $service => $status) {
|
||||
if ($specificService && $service !== $specificService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stateColor = match ($status['state']) {
|
||||
'closed' => 'info',
|
||||
'half_open' => 'warning',
|
||||
'open' => 'error',
|
||||
default => 'comment',
|
||||
};
|
||||
|
||||
$output->writeLine(sprintf(
|
||||
" <{$stateColor}>%s</{$stateColor}> - State: <{$stateColor}>%s</{$stateColor}> | Failures: %d | Successes: %d",
|
||||
$service,
|
||||
strtoupper($status['state']),
|
||||
$status['failure_count'],
|
||||
$status['success_count']
|
||||
));
|
||||
|
||||
if ($status['last_failure_time']) {
|
||||
$lastFailure = date('Y-m-d H:i:s', $status['last_failure_time']);
|
||||
$output->writeLine(" Last failure: {$lastFailure}");
|
||||
}
|
||||
|
||||
if ($status['configuration']) {
|
||||
$config = $status['configuration'];
|
||||
$output->writeLine(sprintf(
|
||||
" Config: Threshold=%d, Timeout=%ds, HalfOpen=%d, Success=%d",
|
||||
$config['failure_threshold'],
|
||||
$config['recovery_timeout_seconds'],
|
||||
$config['half_open_max_attempts'],
|
||||
$config['success_threshold']
|
||||
));
|
||||
}
|
||||
|
||||
$output->writeLine('');
|
||||
}
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleStats(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$stats = $this->circuitBreakerManager->getGlobalStatistics();
|
||||
|
||||
$output->writeLine('<info>Global Circuit Breaker Statistics</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
$output->writeLine(sprintf('Total Services: <comment>%d</comment>', $stats['total_services']));
|
||||
$output->writeLine(sprintf('Healthy Services: <info>%d</info>', $stats['healthy_services']));
|
||||
$output->writeLine(sprintf('Degraded Services: <warning>%d</warning>', $stats['degraded_services']));
|
||||
$output->writeLine(sprintf('Failed Services: <error>%d</error>', $stats['failed_services']));
|
||||
$output->writeLine('');
|
||||
|
||||
$output->writeLine('Circuit Breaker States:');
|
||||
$output->writeLine(sprintf(' Closed: <info>%d</info>', $stats['circuit_breakers_closed']));
|
||||
$output->writeLine(sprintf(' Half-Open: <warning>%d</warning>', $stats['circuit_breakers_half_open']));
|
||||
$output->writeLine(sprintf(' Open: <error>%d</error>', $stats['circuit_breakers_open']));
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleHealth(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$output->writeLine('<info>Performing health checks...</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
$healthResults = $this->circuitBreakerManager->performHealthChecks();
|
||||
|
||||
foreach ($healthResults as $namespace => $services) {
|
||||
if (empty($services)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeLine("<comment>Namespace: {$namespace}</comment>");
|
||||
|
||||
foreach ($services as $service => $result) {
|
||||
$statusColor = match ($result['status']) {
|
||||
'healthy' => 'info',
|
||||
'degraded' => 'warning',
|
||||
'failed' => 'error',
|
||||
default => 'comment',
|
||||
};
|
||||
|
||||
$output->writeLine(sprintf(
|
||||
" <{$statusColor}>%s</{$statusColor}> - %s (checked at %s)",
|
||||
$service,
|
||||
ucfirst($result['status']),
|
||||
$result['checked_at']
|
||||
));
|
||||
}
|
||||
|
||||
$output->writeLine('');
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleReset(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$service = $args[1] ?? null;
|
||||
|
||||
if ($service) {
|
||||
$namespace = $args[2] ?? 'default';
|
||||
$this->circuitBreakerManager->resetService($service, $namespace);
|
||||
$output->writeLine("<info>Circuit breaker for service '{$service}' in namespace '{$namespace}' has been reset</info>");
|
||||
} else {
|
||||
$confirm = $input->getOption('force') || $this->confirmReset($input, $output);
|
||||
|
||||
if ($confirm) {
|
||||
$this->circuitBreakerManager->resetAll();
|
||||
$output->writeLine('<info>All circuit breakers have been reset</info>');
|
||||
} else {
|
||||
$output->writeLine('<comment>Reset cancelled</comment>');
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleConfig(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$config = $this->circuitBreakerManager->exportConfiguration();
|
||||
|
||||
$output->writeLine('<info>Circuit Breaker Configuration</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
$output->writeLine(sprintf('Circuit Breakers: %s', implode(', ', $config['circuit_breakers'])));
|
||||
$output->writeLine(sprintf('Generated: %s', $config['timestamp']));
|
||||
$output->writeLine(sprintf('Version: %s', $config['version']));
|
||||
$output->writeLine('');
|
||||
|
||||
if (! empty($config['configurations'])) {
|
||||
$output->writeLine('<comment>Service Configurations:</comment>');
|
||||
|
||||
foreach ($config['configurations'] as $service => $serviceConfig) {
|
||||
$output->writeLine(" {$service}:");
|
||||
$output->writeLine(" Failure Threshold: {$serviceConfig->failureThreshold}");
|
||||
$output->writeLine(" Recovery Timeout: {$serviceConfig->recoveryTimeoutSeconds}s");
|
||||
$output->writeLine(" Half-Open Max Attempts: {$serviceConfig->halfOpenMaxAttempts}");
|
||||
$output->writeLine(" Success Threshold: {$serviceConfig->successThreshold}");
|
||||
$output->writeLine('');
|
||||
}
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleExport(ConsoleInput $input, ConsoleOutput $output): ExitCode
|
||||
{
|
||||
$config = $this->circuitBreakerManager->exportConfiguration();
|
||||
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$filename = $input->getOption('output') ?? 'circuit-breaker-config.json';
|
||||
|
||||
if (file_put_contents($filename, $json) !== false) {
|
||||
$output->writeLine("<info>Configuration exported to {$filename}</info>");
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} else {
|
||||
$output->writeLine("<error>Failed to write configuration to {$filename}</error>");
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function confirmReset(ConsoleInput $input, ConsoleOutput $output): bool
|
||||
{
|
||||
$output->write('<warning>This will reset ALL circuit breakers. Are you sure? (y/N): </warning>');
|
||||
|
||||
$handle = fopen('php://stdin', 'r');
|
||||
$response = trim(fgets($handle));
|
||||
fclose($handle);
|
||||
|
||||
return strtolower($response) === 'y' || strtolower($response) === 'yes';
|
||||
}
|
||||
}
|
||||
111
src/Framework/CircuitBreaker/DatabaseCircuitBreaker.php
Normal file
111
src/Framework/CircuitBreaker/DatabaseCircuitBreaker.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
use PDOException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Circuit Breaker speziell für Datenbankverbindungen
|
||||
*/
|
||||
final readonly class DatabaseCircuitBreaker
|
||||
{
|
||||
private const string SERVICE_NAME = 'database';
|
||||
|
||||
public function __construct(
|
||||
private CircuitBreaker $circuitBreaker,
|
||||
private ?CircuitBreakerConfig $config = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine Datenbankoperation mit Circuit Breaker Schutz aus
|
||||
*
|
||||
* @template T
|
||||
* @param callable(ConnectionInterface): T $operation
|
||||
* @return T
|
||||
* @throws CircuitBreakerException|DatabaseException|Throwable
|
||||
*/
|
||||
public function execute(ConnectionInterface $connection, callable $operation): mixed
|
||||
{
|
||||
$config = $this->config ?? $this->getDefaultDatabaseConfig();
|
||||
|
||||
return $this->circuitBreaker->execute(
|
||||
service: self::SERVICE_NAME,
|
||||
operation: function () use ($connection, $operation) {
|
||||
try {
|
||||
return $operation($connection);
|
||||
} catch (PDOException $e) {
|
||||
// PDO Exceptions in DatabaseException wrappen für einheitliche Behandlung
|
||||
throw new DatabaseException(
|
||||
"Database operation failed: " . $e->getMessage(),
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
},
|
||||
config: $config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Datenbankverbindung
|
||||
*/
|
||||
public function healthCheck(ConnectionInterface $connection): bool
|
||||
{
|
||||
try {
|
||||
$this->execute($connection, function ($conn) {
|
||||
// Einfache Query für Health Check
|
||||
$conn->query('SELECT 1');
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuellen Status des Database Circuit Breakers zurück
|
||||
*/
|
||||
public function getStatus(): array
|
||||
{
|
||||
$metrics = $this->circuitBreaker->getMetrics(self::SERVICE_NAME);
|
||||
|
||||
return [
|
||||
'service' => self::SERVICE_NAME,
|
||||
'state' => $metrics['state'],
|
||||
'failure_count' => $metrics['failure_count'],
|
||||
'success_count' => $metrics['success_count'],
|
||||
'last_failure_time' => $metrics['last_failure_time'],
|
||||
'health_status' => $metrics['state'] === 'closed' ? 'healthy' : 'degraded',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Database Circuit Breaker zurück
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->circuitBreaker->reset(self::SERVICE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Konfiguration für Datenbank Circuit Breaker
|
||||
*/
|
||||
private function getDefaultDatabaseConfig(): CircuitBreakerConfig
|
||||
{
|
||||
return new CircuitBreakerConfig(
|
||||
failureThreshold: 3,
|
||||
recoveryTimeout: Duration::fromSeconds(30),
|
||||
halfOpenMaxAttempts: 2,
|
||||
successThreshold: 2
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/Framework/CircuitBreaker/Events/CircuitBreakerClosed.php
Normal file
61
src/Framework/CircuitBreaker/Events/CircuitBreakerClosed.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Events;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerMetrics;
|
||||
use App\Framework\CircuitBreaker\CircuitState;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Event fired when a circuit breaker closes (transitions to CLOSED state)
|
||||
*/
|
||||
final readonly class CircuitBreakerClosed implements CircuitBreakerEvent
|
||||
{
|
||||
public string $eventType;
|
||||
|
||||
public string $severity;
|
||||
|
||||
public CircuitState $newState;
|
||||
|
||||
public Timestamp $occurredAt;
|
||||
|
||||
public function __construct(
|
||||
public string $service,
|
||||
public string $namespace,
|
||||
public CircuitState $previousState,
|
||||
public CircuitBreakerMetrics $metrics,
|
||||
public ?string $reason = null,
|
||||
?Timestamp $occurredAt = null,
|
||||
) {
|
||||
$this->eventType = 'circuit_breaker_closed';
|
||||
$this->severity = 'info';
|
||||
$this->newState = CircuitState::CLOSED;
|
||||
$this->occurredAt = $occurredAt ?? Timestamp::now();
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
$successes = $this->metrics->successCount;
|
||||
$reason = $this->reason ? " (Reason: {$this->reason})" : '';
|
||||
|
||||
return "Circuit breaker for '{$this->service}' closed after {$successes} successful attempts{$reason}";
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_type' => $this->eventType,
|
||||
'service' => $this->service,
|
||||
'namespace' => $this->namespace,
|
||||
'previous_state' => $this->previousState->value,
|
||||
'new_state' => $this->newState->value,
|
||||
'reason' => $this->reason,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'description' => $this->getDescription(),
|
||||
'metrics' => $this->metrics->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
src/Framework/CircuitBreaker/Events/CircuitBreakerEvent.php
Normal file
37
src/Framework/CircuitBreaker/Events/CircuitBreakerEvent.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Events;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerMetrics;
|
||||
use App\Framework\CircuitBreaker\CircuitState;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Interface for all Circuit Breaker events
|
||||
*/
|
||||
interface CircuitBreakerEvent
|
||||
{
|
||||
public string $service {get;}
|
||||
|
||||
public string $namespace {get;}
|
||||
|
||||
public CircuitState $previousState {get;}
|
||||
|
||||
public CircuitState $newState {get;}
|
||||
|
||||
public CircuitBreakerMetrics $metrics {get;}
|
||||
|
||||
public Timestamp $occurredAt {get;}
|
||||
|
||||
public ?string $reason {get;}
|
||||
|
||||
public string $eventType {get;}
|
||||
|
||||
public string $severity {get;}
|
||||
|
||||
public function getDescription(): string;
|
||||
|
||||
public function toArray(): array;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Events;
|
||||
|
||||
use App\Framework\EventBus\EventBus;
|
||||
use App\Framework\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Publishes Circuit Breaker events to the event bus and logs them
|
||||
*/
|
||||
final readonly class CircuitBreakerEventPublisher
|
||||
{
|
||||
public function __construct(
|
||||
private EventBus $eventBus,
|
||||
private ?Logger $logger = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a circuit breaker event
|
||||
*/
|
||||
public function publish(CircuitBreakerEvent $event): void
|
||||
{
|
||||
// Publish to event bus for other components to handle
|
||||
$this->eventBus->dispatch($event);
|
||||
|
||||
// Log the event
|
||||
$this->logEvent($event);
|
||||
}
|
||||
|
||||
private function logEvent(CircuitBreakerEvent $event): void
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $event->getDescription();
|
||||
$context = $event->toArray();
|
||||
|
||||
match ($event->severity) {
|
||||
'critical' => $this->logger->critical($message, $context),
|
||||
'error' => $this->logger->error($message, $context),
|
||||
'warning' => $this->logger->warning($message, $context),
|
||||
'info' => $this->logger->info($message, $context),
|
||||
'debug' => $this->logger->debug($message, $context),
|
||||
default => $this->logger->notice($message, $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Events;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerMetrics;
|
||||
use App\Framework\CircuitBreaker\CircuitState;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Event fired when a circuit breaker transitions to HALF_OPEN state
|
||||
*/
|
||||
final readonly class CircuitBreakerHalfOpened implements CircuitBreakerEvent
|
||||
{
|
||||
public string $eventType;
|
||||
|
||||
public string $severity;
|
||||
|
||||
public CircuitState $newState;
|
||||
|
||||
public Timestamp $occurredAt;
|
||||
|
||||
public function __construct(
|
||||
public string $service,
|
||||
public string $namespace,
|
||||
public CircuitState $previousState,
|
||||
public CircuitBreakerMetrics $metrics,
|
||||
public ?string $reason = null,
|
||||
?Timestamp $occurredAt = null,
|
||||
) {
|
||||
$this->eventType = 'circuit_breaker_half_opened';
|
||||
$this->severity = 'info';
|
||||
$this->newState = CircuitState::HALF_OPEN;
|
||||
$this->occurredAt = $occurredAt ?? Timestamp::now();
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
$uptime = $this->metrics->getUptimeDuration()?->toHumanReadable() ?? 'unknown';
|
||||
$reason = $this->reason ? " (Reason: {$this->reason})" : '';
|
||||
|
||||
return "Circuit breaker for '{$this->service}' transitioned to HALF_OPEN after {$uptime} recovery time{$reason}";
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_type' => $this->eventType,
|
||||
'service' => $this->service,
|
||||
'namespace' => $this->namespace,
|
||||
'previous_state' => $this->previousState->value,
|
||||
'new_state' => $this->newState->value,
|
||||
'reason' => $this->reason,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'description' => $this->getDescription(),
|
||||
'metrics' => $this->metrics->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
src/Framework/CircuitBreaker/Events/CircuitBreakerOpened.php
Normal file
61
src/Framework/CircuitBreaker/Events/CircuitBreakerOpened.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Events;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerMetrics;
|
||||
use App\Framework\CircuitBreaker\CircuitState;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
|
||||
/**
|
||||
* Event fired when a circuit breaker opens (transitions to OPEN state)
|
||||
*/
|
||||
final readonly class CircuitBreakerOpened implements CircuitBreakerEvent
|
||||
{
|
||||
public string $eventType;
|
||||
|
||||
public string $severity;
|
||||
|
||||
public CircuitState $newState;
|
||||
|
||||
public Timestamp $occurredAt;
|
||||
|
||||
public function __construct(
|
||||
public string $service,
|
||||
public string $namespace,
|
||||
public CircuitState $previousState,
|
||||
public CircuitBreakerMetrics $metrics,
|
||||
public ?string $reason = null,
|
||||
?Timestamp $occurredAt = null,
|
||||
) {
|
||||
$this->eventType = 'circuit_breaker_opened';
|
||||
$this->severity = 'warning';
|
||||
$this->newState = CircuitState::OPEN;
|
||||
$this->occurredAt = $occurredAt ?? Timestamp::now();
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
$failures = $this->metrics->failureCount;
|
||||
$reason = $this->reason ? " (Reason: {$this->reason})" : '';
|
||||
|
||||
return "Circuit breaker for '{$this->service}' opened after {$failures} failures{$reason}";
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'event_type' => $this->eventType,
|
||||
'service' => $this->service,
|
||||
'namespace' => $this->namespace,
|
||||
'previous_state' => $this->previousState->value,
|
||||
'new_state' => $this->newState->value,
|
||||
'reason' => $this->reason,
|
||||
'occurred_at' => $this->occurredAt->toIsoString(),
|
||||
'severity' => $this->severity,
|
||||
'description' => $this->getDescription(),
|
||||
'metrics' => $this->metrics->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Examples;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerConfig;
|
||||
use App\Framework\CircuitBreaker\FailurePredicate\FailurePredicateFactory;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\HttpClient\Exception\ServerErrorException;
|
||||
use App\Framework\Validation\Exceptions\ValidationException;
|
||||
|
||||
/**
|
||||
* Usage examples for the new FailurePredicate system
|
||||
*/
|
||||
final class FailurePredicateUsageExample
|
||||
{
|
||||
/**
|
||||
* Example 1: Using factory methods for common scenarios
|
||||
*/
|
||||
public function exampleFactoryUsage(): void
|
||||
{
|
||||
// For external services - triggers on server errors, excludes validation errors
|
||||
$externalServiceConfig = CircuitBreakerConfig::forExternalService();
|
||||
|
||||
// For database operations - only triggers on database-related exceptions
|
||||
$databaseConfig = CircuitBreakerConfig::forDatabase();
|
||||
|
||||
// For HTTP clients - comprehensive HTTP error handling
|
||||
$httpClientConfig = CircuitBreakerConfig::forHttpClient();
|
||||
|
||||
// Only timeout errors
|
||||
$timeoutConfig = CircuitBreakerConfig::forTimeoutErrors();
|
||||
|
||||
// Critical errors only (500+ status codes, framework errors)
|
||||
$criticalConfig = CircuitBreakerConfig::strict();
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 2: Using the fluent builder for custom predicates
|
||||
*/
|
||||
public function exampleBuilderUsage(): void
|
||||
{
|
||||
// Complex predicate: Include server errors OR specific exceptions, exclude validation
|
||||
$customPredicate = FailurePredicateFactory::builder()
|
||||
->includeServerErrors() // 5xx HTTP status codes
|
||||
->includeExceptions([
|
||||
ServerErrorException::class,
|
||||
\PDOException::class,
|
||||
])
|
||||
->excludeExceptions([
|
||||
ValidationException::class,
|
||||
\InvalidArgumentException::class,
|
||||
])
|
||||
->includeStatusCodes([429, 503, 504]) // Rate limiting and gateway errors
|
||||
->addCallback(
|
||||
fn (\Throwable $e, string $service) => str_contains($e->getMessage(), 'timeout'),
|
||||
'Timeout detection'
|
||||
)
|
||||
->or() // Use OR logic instead of AND
|
||||
->build();
|
||||
|
||||
$config = CircuitBreakerConfig::withFailurePredicate(
|
||||
failurePredicate: $customPredicate,
|
||||
failureThreshold: 3,
|
||||
recoveryTimeout: Duration::fromMinutes(2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 3: Service-specific predicates
|
||||
*/
|
||||
public function exampleServiceSpecificPredicates(): void
|
||||
{
|
||||
// Different predicates for different services
|
||||
$paymentServicePredicate = FailurePredicateFactory::builder()
|
||||
->includeServerErrors()
|
||||
->includeStatusCodes([429, 502, 503, 504])
|
||||
->excludeExceptions([ValidationException::class])
|
||||
->and()
|
||||
->build();
|
||||
|
||||
$emailServicePredicate = FailurePredicateFactory::builder()
|
||||
->includeExceptions([
|
||||
\App\Framework\HttpClient\Exception\CurlExecutionFailed::class,
|
||||
ServerErrorException::class,
|
||||
])
|
||||
->addCallback(
|
||||
fn (\Throwable $e, string $service) => $service === 'email-service' &&
|
||||
str_contains($e->getMessage(), 'rate limit'),
|
||||
'Email service rate limiting'
|
||||
)
|
||||
->or()
|
||||
->build();
|
||||
|
||||
$paymentConfig = CircuitBreakerConfig::withFailurePredicate($paymentServicePredicate);
|
||||
$emailConfig = CircuitBreakerConfig::withFailurePredicate($emailServicePredicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 4: Advanced combinations with logical operators
|
||||
*/
|
||||
public function exampleAdvancedCombinations(): void
|
||||
{
|
||||
// Predicate that triggers when:
|
||||
// (Server errors OR specific exceptions) AND NOT validation errors
|
||||
$complexPredicate = FailurePredicateFactory::builder()
|
||||
->includeServerErrors()
|
||||
->includeExceptions([
|
||||
ServerErrorException::class,
|
||||
\RuntimeException::class,
|
||||
])
|
||||
->and() // AND logic for above conditions
|
||||
->build();
|
||||
|
||||
// Combine with NOT predicate for validation errors
|
||||
$validationExclusion = FailurePredicateFactory::builder()
|
||||
->includeExceptions([
|
||||
ValidationException::class,
|
||||
\InvalidArgumentException::class,
|
||||
])
|
||||
->build();
|
||||
|
||||
// This creates: (ServerErrors OR RuntimeException) AND NOT ValidationErrors
|
||||
$finalPredicate = FailurePredicateFactory::builder()
|
||||
->addPredicate($complexPredicate)
|
||||
->addPredicate(\App\Framework\CircuitBreaker\FailurePredicate\CompositeFailurePredicate::not([$validationExclusion]))
|
||||
->and()
|
||||
->build();
|
||||
|
||||
$config = CircuitBreakerConfig::withFailurePredicate($finalPredicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 5: Message-based predicates
|
||||
*/
|
||||
public function exampleMessageBasedPredicates(): void
|
||||
{
|
||||
// Trigger only on timeout-related messages
|
||||
$timeoutPredicate = FailurePredicateFactory::builder()
|
||||
->addCallback(
|
||||
\App\Framework\CircuitBreaker\FailurePredicate\CallbackFailurePredicate::messageContains('timeout')
|
||||
->shouldTrigger(...),
|
||||
'Timeout message detection'
|
||||
)
|
||||
->addCallback(
|
||||
\App\Framework\CircuitBreaker\FailurePredicate\CallbackFailurePredicate::messageMatches('/connection.*failed/i')
|
||||
->shouldTrigger(...),
|
||||
'Connection failure pattern'
|
||||
)
|
||||
->or()
|
||||
->build();
|
||||
|
||||
$config = CircuitBreakerConfig::withFailurePredicate($timeoutPredicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 6: Testing and debugging predicates
|
||||
*/
|
||||
public function exampleTestingPredicates(): void
|
||||
{
|
||||
// Never trigger (useful for testing)
|
||||
$neverTrigger = FailurePredicateFactory::never();
|
||||
|
||||
// Always trigger (useful for testing)
|
||||
$alwaysTrigger = FailurePredicateFactory::always();
|
||||
|
||||
// Custom test predicate
|
||||
$testPredicate = FailurePredicateFactory::builder()
|
||||
->addCallback(
|
||||
fn (\Throwable $e, string $service) =>
|
||||
$service === 'test-service' && get_class($e) === \RuntimeException::class,
|
||||
'Test-specific predicate'
|
||||
)
|
||||
->build();
|
||||
|
||||
$testConfig = CircuitBreakerConfig::withFailurePredicate($testPredicate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Predicate that uses a callback function for custom logic
|
||||
*/
|
||||
final readonly class CallbackFailurePredicate implements FailurePredicate
|
||||
{
|
||||
/**
|
||||
* @param callable(Throwable, string): bool $callback
|
||||
*/
|
||||
public function __construct(
|
||||
private mixed $callback,
|
||||
private string $identifier = 'callback',
|
||||
private string $description = 'Custom callback predicate',
|
||||
) {
|
||||
if (! is_callable($this->callback)) {
|
||||
throw new \InvalidArgumentException('Callback must be callable');
|
||||
}
|
||||
}
|
||||
|
||||
public function shouldTrigger(Throwable $exception, string $service): bool
|
||||
{
|
||||
try {
|
||||
return (bool) ($this->callback)($exception, $service);
|
||||
} catch (Throwable $e) {
|
||||
// If callback throws, don't trigger circuit breaker
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate from closure
|
||||
*/
|
||||
public static function from(callable $callback, string $description = 'Custom callback'): self
|
||||
{
|
||||
return new self($callback, 'callback', $description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that checks exception message
|
||||
*/
|
||||
public static function messageContains(string $substring): self
|
||||
{
|
||||
return new self(
|
||||
callback: fn (Throwable $e, string $service) => str_contains($e->getMessage(), $substring),
|
||||
identifier: 'message_contains',
|
||||
description: "Message contains: '{$substring}'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that checks exception message with regex
|
||||
*/
|
||||
public static function messageMatches(string $pattern): self
|
||||
{
|
||||
return new self(
|
||||
callback: fn (Throwable $e, string $service) => preg_match($pattern, $e->getMessage()) === 1,
|
||||
identifier: 'message_regex',
|
||||
description: "Message matches: {$pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate based on service name
|
||||
*/
|
||||
public static function forService(string $targetService): self
|
||||
{
|
||||
return new self(
|
||||
callback: fn (Throwable $e, string $service) => $service === $targetService,
|
||||
identifier: 'service_specific',
|
||||
description: "Only for service: {$targetService}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Composite predicate that combines multiple predicates with logical operators
|
||||
*/
|
||||
final readonly class CompositeFailurePredicate implements FailurePredicate
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* @var array<FailurePredicate>
|
||||
*/
|
||||
private array $predicates,
|
||||
private LogicalOperator $operator = LogicalOperator::AND,
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldTrigger(Throwable $exception, string $service): bool
|
||||
{
|
||||
if (empty($this->predicates)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($this->operator) {
|
||||
LogicalOperator::AND => $this->evaluateAnd($exception, $service),
|
||||
LogicalOperator::OR => $this->evaluateOr($exception, $service),
|
||||
LogicalOperator::NOT => $this->evaluateNot($exception, $service),
|
||||
};
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
$identifiers = array_map(fn (FailurePredicate $p) => $p->getIdentifier(), $this->predicates);
|
||||
|
||||
return 'composite(' . $this->operator->value . ':' . implode(',', $identifiers) . ')';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
$descriptions = array_map(fn (FailurePredicate $p) => $p->getDescription(), $this->predicates);
|
||||
|
||||
return match ($this->operator) {
|
||||
LogicalOperator::AND => '(' . implode(' AND ', $descriptions) . ')',
|
||||
LogicalOperator::OR => '(' . implode(' OR ', $descriptions) . ')',
|
||||
LogicalOperator::NOT => 'NOT (' . implode(', ', $descriptions) . ')',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create AND composite predicate
|
||||
*
|
||||
* @param array<FailurePredicate> $predicates
|
||||
*/
|
||||
public static function and(array $predicates): self
|
||||
{
|
||||
return new self($predicates, LogicalOperator::AND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OR composite predicate
|
||||
*
|
||||
* @param array<FailurePredicate> $predicates
|
||||
*/
|
||||
public static function or(array $predicates): self
|
||||
{
|
||||
return new self($predicates, LogicalOperator::OR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NOT composite predicate
|
||||
*
|
||||
* @param array<FailurePredicate> $predicates
|
||||
*/
|
||||
public static function not(array $predicates): self
|
||||
{
|
||||
return new self($predicates, LogicalOperator::NOT);
|
||||
}
|
||||
|
||||
private function evaluateAnd(Throwable $exception, string $service): bool
|
||||
{
|
||||
foreach ($this->predicates as $predicate) {
|
||||
if (! $predicate->shouldTrigger($exception, $service)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function evaluateOr(Throwable $exception, string $service): bool
|
||||
{
|
||||
foreach ($this->predicates as $predicate) {
|
||||
if ($predicate->shouldTrigger($exception, $service)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function evaluateNot(Throwable $exception, string $service): bool
|
||||
{
|
||||
foreach ($this->predicates as $predicate) {
|
||||
if ($predicate->shouldTrigger($exception, $service)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Predicate that matches specific exception types
|
||||
*/
|
||||
final readonly class ExceptionTypeFailurePredicate implements FailurePredicate
|
||||
{
|
||||
/**
|
||||
* @param array<class-string<Throwable>> $includedExceptions
|
||||
* @param array<class-string<Throwable>> $excludedExceptions
|
||||
*/
|
||||
public function __construct(
|
||||
private array $includedExceptions = [],
|
||||
private array $excludedExceptions = [],
|
||||
private bool $includeAllByDefault = true,
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldTrigger(Throwable $exception, string $service): bool
|
||||
{
|
||||
// Check excluded exceptions first
|
||||
foreach ($this->excludedExceptions as $excludedClass) {
|
||||
if ($exception instanceof $excludedClass) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no included exceptions specified, use default behavior
|
||||
if (empty($this->includedExceptions)) {
|
||||
return $this->includeAllByDefault;
|
||||
}
|
||||
|
||||
// Check if exception matches included types
|
||||
foreach ($this->includedExceptions as $includedClass) {
|
||||
if ($exception instanceof $includedClass) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return 'exception_type';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
$included = empty($this->includedExceptions)
|
||||
? ($this->includeAllByDefault ? 'all' : 'none')
|
||||
: implode(', ', array_map(fn ($class) => class_basename($class), $this->includedExceptions));
|
||||
|
||||
$excluded = empty($this->excludedExceptions)
|
||||
? 'none'
|
||||
: implode(', ', array_map(fn ($class) => class_basename($class), $this->excludedExceptions));
|
||||
|
||||
return "Include: {$included}, Exclude: {$excluded}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that only triggers on specific exception types
|
||||
*
|
||||
* @param array<class-string<Throwable>> $exceptionTypes
|
||||
*/
|
||||
public static function only(array $exceptionTypes): self
|
||||
{
|
||||
return new self(
|
||||
includedExceptions: $exceptionTypes,
|
||||
includeAllByDefault: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that triggers on all exceptions except specified types
|
||||
*
|
||||
* @param array<class-string<Throwable>> $exceptionTypes
|
||||
*/
|
||||
public static function except(array $exceptionTypes): self
|
||||
{
|
||||
return new self(
|
||||
excludedExceptions: $exceptionTypes,
|
||||
includeAllByDefault: true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that triggers on all exceptions
|
||||
*/
|
||||
public static function all(): self
|
||||
{
|
||||
return new self(includeAllByDefault: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that never triggers
|
||||
*/
|
||||
public static function none(): self
|
||||
{
|
||||
return new self(includeAllByDefault: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Interface for determining if an exception should trigger circuit breaker failure
|
||||
*/
|
||||
interface FailurePredicate
|
||||
{
|
||||
/**
|
||||
* Determine if the exception should trigger a circuit breaker failure
|
||||
*/
|
||||
public function shouldTrigger(Throwable $exception, string $service): bool;
|
||||
|
||||
/**
|
||||
* Get predicate identifier for logging/debugging
|
||||
*/
|
||||
public function getIdentifier(): string;
|
||||
|
||||
/**
|
||||
* Get human-readable description
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Fluent builder for creating failure predicates
|
||||
*/
|
||||
final class FailurePredicateBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<class-string<Throwable>>
|
||||
*/
|
||||
private array $includedExceptions = [];
|
||||
|
||||
/**
|
||||
* @var array<class-string<Throwable>>
|
||||
*/
|
||||
private array $excludedExceptions = [];
|
||||
|
||||
/**
|
||||
* @var array<int>
|
||||
*/
|
||||
private array $includedStatusCodes = [];
|
||||
|
||||
/**
|
||||
* @var array<int>
|
||||
*/
|
||||
private array $excludedStatusCodes = [];
|
||||
|
||||
/**
|
||||
* @var array<FailurePredicate>
|
||||
*/
|
||||
private array $additionalPredicates = [];
|
||||
|
||||
private bool $includeServerErrors = false;
|
||||
|
||||
private bool $includeClientErrors = false;
|
||||
|
||||
private LogicalOperator $operator = LogicalOperator::AND;
|
||||
|
||||
/**
|
||||
* Include specific exception types
|
||||
*
|
||||
* @param array<class-string<Throwable>> $exceptionTypes
|
||||
*/
|
||||
public function includeExceptions(array $exceptionTypes): self
|
||||
{
|
||||
$this->includedExceptions = array_merge($this->includedExceptions, $exceptionTypes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude specific exception types
|
||||
*
|
||||
* @param array<class-string<Throwable>> $exceptionTypes
|
||||
*/
|
||||
public function excludeExceptions(array $exceptionTypes): self
|
||||
{
|
||||
$this->excludedExceptions = array_merge($this->excludedExceptions, $exceptionTypes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include specific HTTP status codes
|
||||
*
|
||||
* @param array<int> $statusCodes
|
||||
*/
|
||||
public function includeStatusCodes(array $statusCodes): self
|
||||
{
|
||||
$this->includedStatusCodes = array_merge($this->includedStatusCodes, $statusCodes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude specific HTTP status codes
|
||||
*
|
||||
* @param array<int> $statusCodes
|
||||
*/
|
||||
public function excludeStatusCodes(array $statusCodes): self
|
||||
{
|
||||
$this->excludedStatusCodes = array_merge($this->excludedStatusCodes, $statusCodes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include server errors (5xx)
|
||||
*/
|
||||
public function includeServerErrors(): self
|
||||
{
|
||||
$this->includeServerErrors = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include client errors (4xx)
|
||||
*/
|
||||
public function includeClientErrors(): self
|
||||
{
|
||||
$this->includeClientErrors = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom predicate
|
||||
*/
|
||||
public function addPredicate(FailurePredicate $predicate): self
|
||||
{
|
||||
$this->additionalPredicates[] = $predicate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add callback predicate
|
||||
*/
|
||||
public function addCallback(callable $callback, string $description = 'Custom callback'): self
|
||||
{
|
||||
$this->additionalPredicates[] = new CallbackFailurePredicate($callback, 'callback', $description);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set logical operator for combining predicates
|
||||
*/
|
||||
public function withOperator(LogicalOperator $operator): self
|
||||
{
|
||||
$this->operator = $operator;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use AND operator (default)
|
||||
*/
|
||||
public function and(): self
|
||||
{
|
||||
$this->operator = LogicalOperator::AND;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use OR operator
|
||||
*/
|
||||
public function or(): self
|
||||
{
|
||||
$this->operator = LogicalOperator::OR;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the failure predicate
|
||||
*/
|
||||
public function build(): FailurePredicate
|
||||
{
|
||||
$predicates = [];
|
||||
|
||||
// Add exception type predicate if needed
|
||||
if (! empty($this->includedExceptions) || ! empty($this->excludedExceptions)) {
|
||||
$predicates[] = new ExceptionTypeFailurePredicate(
|
||||
includedExceptions: $this->includedExceptions,
|
||||
excludedExceptions: $this->excludedExceptions,
|
||||
includeAllByDefault: empty($this->includedExceptions)
|
||||
);
|
||||
}
|
||||
|
||||
// Add HTTP status predicate if needed
|
||||
if (! empty($this->includedStatusCodes) || ! empty($this->excludedStatusCodes) ||
|
||||
$this->includeServerErrors || $this->includeClientErrors) {
|
||||
$predicates[] = new HttpStatusFailurePredicate(
|
||||
includedStatusCodes: $this->includedStatusCodes,
|
||||
excludedStatusCodes: $this->excludedStatusCodes,
|
||||
includeServerErrors: $this->includeServerErrors,
|
||||
includeClientErrors: $this->includeClientErrors
|
||||
);
|
||||
}
|
||||
|
||||
// Add additional predicates
|
||||
$predicates = array_merge($predicates, $this->additionalPredicates);
|
||||
|
||||
// Return appropriate predicate
|
||||
if (empty($predicates)) {
|
||||
return ExceptionTypeFailurePredicate::all();
|
||||
}
|
||||
|
||||
if (count($predicates) === 1) {
|
||||
return $predicates[0];
|
||||
}
|
||||
|
||||
return new CompositeFailurePredicate($predicates, $this->operator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use App\Framework\HttpClient\Exception\ServerErrorException;
|
||||
use App\Framework\Validation\Exceptions\ValidationException;
|
||||
|
||||
/**
|
||||
* Factory for creating common failure predicates
|
||||
*/
|
||||
final class FailurePredicateFactory
|
||||
{
|
||||
/**
|
||||
* Create predicate for external service calls
|
||||
*/
|
||||
public static function forExternalService(): FailurePredicate
|
||||
{
|
||||
return CompositeFailurePredicate::and([
|
||||
HttpStatusFailurePredicate::serverErrors(),
|
||||
ExceptionTypeFailurePredicate::except([ValidationException::class]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for database operations
|
||||
*/
|
||||
public static function forDatabase(): FailurePredicate
|
||||
{
|
||||
return ExceptionTypeFailurePredicate::only([
|
||||
\PDOException::class,
|
||||
\App\Framework\Database\Exceptions\DatabaseException::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for HTTP client operations
|
||||
*/
|
||||
public static function forHttpClient(): FailurePredicate
|
||||
{
|
||||
return CompositeFailurePredicate::or([
|
||||
ExceptionTypeFailurePredicate::only([
|
||||
ServerErrorException::class,
|
||||
\App\Framework\HttpClient\Exception\CurlExecutionFailed::class,
|
||||
]),
|
||||
HttpStatusFailurePredicate::forStatusCodes([502, 503, 504, 429]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that excludes validation errors
|
||||
*/
|
||||
public static function excludeValidationErrors(): FailurePredicate
|
||||
{
|
||||
return ExceptionTypeFailurePredicate::except([
|
||||
ValidationException::class,
|
||||
\InvalidArgumentException::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for critical errors only
|
||||
*/
|
||||
public static function criticalErrorsOnly(): FailurePredicate
|
||||
{
|
||||
return CompositeFailurePredicate::or([
|
||||
HttpStatusFailurePredicate::forStatusCodes([500, 502, 503, 504]),
|
||||
ExceptionTypeFailurePredicate::only([
|
||||
\Error::class,
|
||||
\App\Framework\Exception\FrameworkException::class,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for timeout-related errors
|
||||
*/
|
||||
public static function timeoutErrors(): FailurePredicate
|
||||
{
|
||||
return CompositeFailurePredicate::or([
|
||||
CallbackFailurePredicate::messageContains('timeout'),
|
||||
CallbackFailurePredicate::messageContains('timed out'),
|
||||
HttpStatusFailurePredicate::forStatusCodes([408, 504]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that never triggers (for testing/debugging)
|
||||
*/
|
||||
public static function never(): FailurePredicate
|
||||
{
|
||||
return ExceptionTypeFailurePredicate::none();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate that always triggers
|
||||
*/
|
||||
public static function always(): FailurePredicate
|
||||
{
|
||||
return ExceptionTypeFailurePredicate::all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom predicate with fluent builder
|
||||
*/
|
||||
public static function builder(): FailurePredicateBuilder
|
||||
{
|
||||
return new FailurePredicateBuilder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
use App\Framework\HttpClient\Exception\ClientErrorException;
|
||||
use App\Framework\HttpClient\Exception\ServerErrorException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Predicate that matches HTTP status codes
|
||||
*/
|
||||
final readonly class HttpStatusFailurePredicate implements FailurePredicate
|
||||
{
|
||||
/**
|
||||
* @param array<int> $includedStatusCodes
|
||||
* @param array<int> $excludedStatusCodes
|
||||
*/
|
||||
public function __construct(
|
||||
private array $includedStatusCodes = [],
|
||||
private array $excludedStatusCodes = [],
|
||||
private bool $includeServerErrors = true,
|
||||
private bool $includeClientErrors = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldTrigger(Throwable $exception, string $service): bool
|
||||
{
|
||||
$statusCode = $this->extractStatusCode($exception);
|
||||
|
||||
if ($statusCode === null) {
|
||||
// Not an HTTP exception, use default behavior
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check excluded status codes first
|
||||
if (in_array($statusCode, $this->excludedStatusCodes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check included status codes
|
||||
if (! empty($this->includedStatusCodes)) {
|
||||
return in_array($statusCode, $this->includedStatusCodes, true);
|
||||
}
|
||||
|
||||
// Default behavior based on status code ranges
|
||||
if ($statusCode >= 500 && $statusCode < 600) {
|
||||
return $this->includeServerErrors;
|
||||
}
|
||||
|
||||
if ($statusCode >= 400 && $statusCode < 500) {
|
||||
return $this->includeClientErrors;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return 'http_status';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (! empty($this->includedStatusCodes)) {
|
||||
$parts[] = 'Include: ' . implode(', ', $this->includedStatusCodes);
|
||||
} else {
|
||||
$ranges = [];
|
||||
if ($this->includeServerErrors) {
|
||||
$ranges[] = '5xx';
|
||||
}
|
||||
if ($this->includeClientErrors) {
|
||||
$ranges[] = '4xx';
|
||||
}
|
||||
if (! empty($ranges)) {
|
||||
$parts[] = 'Include: ' . implode(', ', $ranges);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($this->excludedStatusCodes)) {
|
||||
$parts[] = 'Exclude: ' . implode(', ', $this->excludedStatusCodes);
|
||||
}
|
||||
|
||||
return empty($parts) ? 'No HTTP status matching' : implode(', ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for specific status codes
|
||||
*
|
||||
* @param array<int> $statusCodes
|
||||
*/
|
||||
public static function forStatusCodes(array $statusCodes): self
|
||||
{
|
||||
return new self(includedStatusCodes: $statusCodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for server errors (5xx)
|
||||
*/
|
||||
public static function serverErrors(): self
|
||||
{
|
||||
return new self(includeServerErrors: true, includeClientErrors: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for client errors (4xx)
|
||||
*/
|
||||
public static function clientErrors(): self
|
||||
{
|
||||
return new self(includeServerErrors: false, includeClientErrors: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate for both client and server errors
|
||||
*/
|
||||
public static function allHttpErrors(): self
|
||||
{
|
||||
return new self(includeServerErrors: true, includeClientErrors: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create predicate excluding specific status codes
|
||||
*
|
||||
* @param array<int> $statusCodes
|
||||
*/
|
||||
public static function except(array $statusCodes): self
|
||||
{
|
||||
return new self(
|
||||
excludedStatusCodes: $statusCodes,
|
||||
includeServerErrors: true,
|
||||
includeClientErrors: true
|
||||
);
|
||||
}
|
||||
|
||||
private function extractStatusCode(Throwable $exception): ?int
|
||||
{
|
||||
if ($exception instanceof ServerErrorException || $exception instanceof ClientErrorException) {
|
||||
return method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : null;
|
||||
}
|
||||
|
||||
// Try to extract from exception message (fallback)
|
||||
if (preg_match('/HTTP (\d{3})/', $exception->getMessage(), $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\FailurePredicate;
|
||||
|
||||
/**
|
||||
* Logical operators for composite predicates
|
||||
*/
|
||||
enum LogicalOperator: string
|
||||
{
|
||||
case AND = 'and';
|
||||
case OR = 'or';
|
||||
case NOT = 'not';
|
||||
}
|
||||
221
src/Framework/CircuitBreaker/HttpClientCircuitBreaker.php
Normal file
221
src/Framework/CircuitBreaker/HttpClientCircuitBreaker.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\HttpClient\ClientRequest;
|
||||
use App\Framework\HttpClient\ClientResponse;
|
||||
use App\Framework\HttpClient\Exception\ClientErrorException;
|
||||
use App\Framework\HttpClient\Exception\HttpClientException;
|
||||
use App\Framework\HttpClient\Exception\ServerErrorException;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Circuit Breaker für HTTP Client Anfragen an externe Services
|
||||
*/
|
||||
final readonly class HttpClientCircuitBreaker
|
||||
{
|
||||
/**
|
||||
* @var array<string, CircuitBreakerConfig>
|
||||
*/
|
||||
private array $serviceConfigs;
|
||||
|
||||
public function __construct(
|
||||
private CircuitBreaker $circuitBreaker,
|
||||
private HttpClient $httpClient,
|
||||
array $serviceConfigs = []
|
||||
) {
|
||||
$this->serviceConfigs = $serviceConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt HTTP Request mit Circuit Breaker Schutz aus
|
||||
*
|
||||
* @throws CircuitBreakerException|HttpClientException
|
||||
*/
|
||||
public function request(ClientRequest $request, ?string $serviceName = null): ClientResponse
|
||||
{
|
||||
$serviceName ??= $this->extractServiceName($request);
|
||||
$config = $this->serviceConfigs[$serviceName] ?? $this->getDefaultConfig();
|
||||
|
||||
return $this->circuitBreaker->execute(
|
||||
service: $serviceName,
|
||||
operation: function () use ($request) {
|
||||
$response = $this->httpClient->request($request);
|
||||
|
||||
// 5xx Status Codes als Fehler behandeln
|
||||
if ($response->getStatusCode() >= 500) {
|
||||
throw new ServerErrorException(
|
||||
"Server error: HTTP {$response->getStatusCode()}",
|
||||
$response->getStatusCode()
|
||||
);
|
||||
}
|
||||
|
||||
// 4xx Status Codes normalerweise nicht als Circuit Breaker Fehler behandeln
|
||||
// außer bei spezifischen Codes wie 429 (Rate Limit)
|
||||
if ($response->getStatusCode() === 429) {
|
||||
throw new ClientErrorException(
|
||||
"Rate limit exceeded: HTTP 429",
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
},
|
||||
config: $config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt GET Request mit Circuit Breaker Schutz aus
|
||||
*/
|
||||
public function get(string $url, array $headers = [], ?string $serviceName = null): ClientResponse
|
||||
{
|
||||
$request = new ClientRequest('GET', $url, $headers);
|
||||
|
||||
return $this->request($request, $serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt POST Request mit Circuit Breaker Schutz aus
|
||||
*/
|
||||
public function post(string $url, mixed $body = null, array $headers = [], ?string $serviceName = null): ClientResponse
|
||||
{
|
||||
$request = new ClientRequest('POST', $url, $headers, $body);
|
||||
|
||||
return $this->request($request, $serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt PUT Request mit Circuit Breaker Schutz aus
|
||||
*/
|
||||
public function put(string $url, mixed $body = null, array $headers = [], ?string $serviceName = null): ClientResponse
|
||||
{
|
||||
$request = new ClientRequest('PUT', $url, $headers, $body);
|
||||
|
||||
return $this->request($request, $serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt DELETE Request mit Circuit Breaker Schutz aus
|
||||
*/
|
||||
public function delete(string $url, array $headers = [], ?string $serviceName = null): ClientResponse
|
||||
{
|
||||
$request = new ClientRequest('DELETE', $url, $headers);
|
||||
|
||||
return $this->request($request, $serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Status aller konfigurierten Services zurück
|
||||
*/
|
||||
public function getServicesStatus(): array
|
||||
{
|
||||
$status = [];
|
||||
|
||||
foreach (array_keys($this->serviceConfigs) as $serviceName) {
|
||||
$metrics = $this->circuitBreaker->getMetrics($serviceName);
|
||||
$status[$serviceName] = [
|
||||
'state' => $metrics['state'],
|
||||
'failure_count' => $metrics['failure_count'],
|
||||
'success_count' => $metrics['success_count'],
|
||||
'health_status' => $metrics['state'] === 'closed' ? 'healthy' : 'degraded',
|
||||
'last_failure_time' => $metrics['last_failure_time'],
|
||||
];
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health Check für einen spezifischen Service
|
||||
*/
|
||||
public function healthCheck(string $serviceName, string $healthCheckUrl): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->get($healthCheckUrl, [], $serviceName);
|
||||
|
||||
return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Circuit Breaker für einen Service zurück
|
||||
*/
|
||||
public function resetService(string $serviceName): void
|
||||
{
|
||||
$this->circuitBreaker->reset($serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alle Service Circuit Breaker zurück
|
||||
*/
|
||||
public function resetAll(): void
|
||||
{
|
||||
foreach (array_keys($this->serviceConfigs) as $serviceName) {
|
||||
$this->circuitBreaker->reset($serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Service-Name aus der URL
|
||||
*/
|
||||
private function extractServiceName(ClientRequest $request): string
|
||||
{
|
||||
$url = $request->getUrl();
|
||||
$parsedUrl = parse_url($url);
|
||||
|
||||
// Service-Name aus Host extrahieren
|
||||
$host = $parsedUrl['host'] ?? 'unknown';
|
||||
|
||||
// Subdomain als Service-Name verwenden falls vorhanden
|
||||
$parts = explode('.', $host);
|
||||
if (count($parts) > 2) {
|
||||
return $parts[0]; // z.B. "api" aus "api.example.com"
|
||||
}
|
||||
|
||||
// Sonst den ganzen Host als Service-Name verwenden
|
||||
return str_replace(['.', '-'], '_', $host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Konfiguration für HTTP Services
|
||||
*/
|
||||
private function getDefaultConfig(): CircuitBreakerConfig
|
||||
{
|
||||
return new CircuitBreakerConfig(
|
||||
failureThreshold: 5,
|
||||
recoveryTimeout: Duration::fromSeconds(60),
|
||||
halfOpenMaxAttempts: 3,
|
||||
successThreshold: 2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für spezifische Services
|
||||
*/
|
||||
public static function withServices(
|
||||
CircuitBreaker $circuitBreaker,
|
||||
HttpClient $httpClient,
|
||||
array $services
|
||||
): self {
|
||||
$configs = [];
|
||||
|
||||
foreach ($services as $serviceName => $config) {
|
||||
if (is_array($config)) {
|
||||
$configs[$serviceName] = new CircuitBreakerConfig(...$config);
|
||||
} elseif ($config instanceof CircuitBreakerConfig) {
|
||||
$configs[$serviceName] = $config;
|
||||
} else {
|
||||
$configs[$serviceName] = CircuitBreakerConfig::forExternalService();
|
||||
}
|
||||
}
|
||||
|
||||
return new self($circuitBreaker, $httpClient, $configs);
|
||||
}
|
||||
}
|
||||
211
src/Framework/CircuitBreaker/README.md
Normal file
211
src/Framework/CircuitBreaker/README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Circuit Breaker Pattern Implementation
|
||||
|
||||
Die Circuit Breaker Implementierung schützt das System vor wiederholten Fehlern durch temporäres Blockieren von Requests nach einer bestimmten Anzahl von Fehlern.
|
||||
|
||||
## Komponenten
|
||||
|
||||
### CircuitState Enum
|
||||
- `CLOSED`: Normal operation, alle Requests werden durchgelassen
|
||||
- `OPEN`: Service ist als fehlerhaft markiert, alle Requests werden abgelehnt
|
||||
- `HALF_OPEN`: Test-Phase, limitierte Requests werden durchgelassen
|
||||
|
||||
### CircuitBreaker (Hauptklasse)
|
||||
Zentrale Circuit Breaker Implementierung mit:
|
||||
- Automatische State-Übergänge basierend auf Fehlern/Erfolgen
|
||||
- Konfigurierbare Schwellenwerte und Timeouts
|
||||
- Cache-basierte Persistierung des Zustands
|
||||
- Retry-Logic mit exponential backoff
|
||||
|
||||
### CircuitBreakerConfig
|
||||
Konfiguration für Circuit Breaker:
|
||||
```php
|
||||
new CircuitBreakerConfig(
|
||||
failureThreshold: 5, // Fehler bis Circuit öffnet
|
||||
recoveryTimeoutSeconds: 60, // Zeit bis HALF_OPEN Test
|
||||
halfOpenMaxAttempts: 3, // Max Versuche im HALF_OPEN
|
||||
successThreshold: 3 // Erfolge bis Circuit schließt
|
||||
);
|
||||
```
|
||||
|
||||
### Spezialisierte Circuit Breaker
|
||||
|
||||
#### DatabaseCircuitBreaker
|
||||
Schutz für Datenbankoperationen:
|
||||
```php
|
||||
$result = $databaseCircuitBreaker->execute($connection, function($conn) {
|
||||
return $conn->query('SELECT * FROM users');
|
||||
});
|
||||
```
|
||||
|
||||
#### HttpClientCircuitBreaker
|
||||
Schutz für externe HTTP-Services:
|
||||
```php
|
||||
$response = $httpCircuitBreaker->get('https://api.example.com/data', [], 'api_service');
|
||||
```
|
||||
|
||||
### CircuitBreakerMiddleware
|
||||
HTTP-Middleware für automatischen Schutz von Routes:
|
||||
```php
|
||||
// Automatischer API-Schutz
|
||||
$middleware = CircuitBreakerMiddleware::forApi($circuitBreaker);
|
||||
|
||||
// Custom Service-Mapping
|
||||
$middleware = new CircuitBreakerMiddleware($circuitBreaker, [
|
||||
'payment_api' => CircuitBreakerConfig::strict(),
|
||||
'notification_service' => CircuitBreakerConfig::forExternalService()
|
||||
]);
|
||||
```
|
||||
|
||||
### CircuitBreakerManager
|
||||
Zentrale Verwaltung und Monitoring:
|
||||
```php
|
||||
$manager = CircuitBreakerManager::withDefaults($cache, $clock, $logger);
|
||||
|
||||
// Status aller Services
|
||||
$status = $manager->getAllServicesStatus();
|
||||
|
||||
// Globale Statistiken
|
||||
$stats = $manager->getGlobalStatistics();
|
||||
|
||||
// Health Checks
|
||||
$health = $manager->performHealthChecks();
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Grundlegende Verwendung
|
||||
```php
|
||||
$circuitBreaker = new CircuitBreaker($cache, $clock, $logger);
|
||||
|
||||
// Operation ausführen
|
||||
try {
|
||||
$result = $circuitBreaker->execute('my_service', function() {
|
||||
// Potentiell fehlerhafte Operation
|
||||
return $externalService->getData();
|
||||
});
|
||||
} catch (CircuitBreakerException $e) {
|
||||
// Circuit ist offen, Service nicht verfügbar
|
||||
return $fallbackData;
|
||||
}
|
||||
```
|
||||
|
||||
### Mit Konfiguration
|
||||
```php
|
||||
$config = new CircuitBreakerConfig(
|
||||
failureThreshold: 3,
|
||||
recoveryTimeoutSeconds: 30
|
||||
);
|
||||
|
||||
$circuitBreaker->execute('critical_service', $operation, $config);
|
||||
```
|
||||
|
||||
### Manuelle Überwachung
|
||||
```php
|
||||
// Status prüfen
|
||||
$state = $circuitBreaker->getState('my_service');
|
||||
|
||||
// Metriken abrufen
|
||||
$metrics = $circuitBreaker->getMetrics('my_service');
|
||||
|
||||
// Circuit zurücksetzen
|
||||
$circuitBreaker->reset('my_service');
|
||||
```
|
||||
|
||||
## Console Commands
|
||||
|
||||
### Status anzeigen
|
||||
```bash
|
||||
# Alle Services
|
||||
php console.php circuit-breaker status
|
||||
|
||||
# Spezifischer Service
|
||||
php console.php circuit-breaker status database
|
||||
|
||||
# Globale Statistiken
|
||||
php console.php circuit-breaker stats
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
php console.php circuit-breaker health
|
||||
```
|
||||
|
||||
### Reset Circuit Breaker
|
||||
```bash
|
||||
# Spezifischer Service
|
||||
php console.php circuit-breaker reset database
|
||||
|
||||
# Alle Services (mit Bestätigung)
|
||||
php console.php circuit-breaker reset
|
||||
|
||||
# Alle Services (ohne Bestätigung)
|
||||
php console.php circuit-breaker reset --force
|
||||
```
|
||||
|
||||
### Konfiguration
|
||||
```bash
|
||||
# Aktuelle Konfiguration anzeigen
|
||||
php console.php circuit-breaker config
|
||||
|
||||
# Konfiguration exportieren
|
||||
php console.php circuit-breaker export
|
||||
php console.php circuit-breaker export --output=my-config.json
|
||||
```
|
||||
|
||||
## Vorkonfigurierte Service-Typen
|
||||
|
||||
### Datenbank Services
|
||||
```php
|
||||
$config = CircuitBreakerConfig::forDatabase(
|
||||
failureThreshold: 3,
|
||||
recoveryTimeoutSeconds: 30
|
||||
);
|
||||
```
|
||||
|
||||
### Externe Services
|
||||
```php
|
||||
$config = CircuitBreakerConfig::forExternalService(
|
||||
failureThreshold: 5,
|
||||
recoveryTimeoutSeconds: 300
|
||||
);
|
||||
```
|
||||
|
||||
### Kritische Services
|
||||
```php
|
||||
$config = CircuitBreakerConfig::strict(
|
||||
failureThreshold: 3,
|
||||
recoveryTimeoutSeconds: 600
|
||||
);
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
Circuit Breaker Exceptions verwenden dedizierte Error Codes:
|
||||
- `SVC001`: Circuit Breaker ist offen
|
||||
- `SVC002`: Circuit Breaker ist half-open
|
||||
- `SVC003`: Service Health Check fehlgeschlagen
|
||||
- `SVC004`: Service ist degradiert
|
||||
|
||||
## Integration mit Error Handling
|
||||
|
||||
Circuit Breaker Exceptions werden automatisch in das Framework Error Handling integriert:
|
||||
- OWASP-konforme Logging
|
||||
- Structured Error Context
|
||||
- HTTP 503 Responses mit Retry-After Header
|
||||
- Security Event Tracking bei kritischen Services
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Service-spezifische Konfiguration**: Verschiedene Services benötigen unterschiedliche Schwellenwerte
|
||||
2. **Monitoring**: Überwachen Sie Circuit Breaker Status und Metriken
|
||||
3. **Fallback-Strategien**: Implementieren Sie Fallback-Logic für geöffnete Circuits
|
||||
4. **Graceful Degradation**: Reduzieren Sie Funktionalität anstatt kompletten Ausfall
|
||||
5. **Health Checks**: Implementieren Sie aktive Health Checks für bessere Recovery
|
||||
|
||||
## Performance
|
||||
|
||||
- Cache-basierte State-Persistierung für minimalen Overhead
|
||||
- Efficient key-based service separation
|
||||
- Configurable TTL für automatische Cleanup
|
||||
- Batch operations für bulk resets
|
||||
- Lock-free state transitions für high concurrency
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Registry;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* Cache-based implementation of ServiceRegistry
|
||||
*/
|
||||
final class CacheBasedServiceRegistry implements ServiceRegistry
|
||||
{
|
||||
private const string REGISTRY_PREFIX = 'circuit_breaker_registry:';
|
||||
private const string NAMESPACE_KEY = 'namespaces';
|
||||
private const string SERVICES_SUFFIX = ':services';
|
||||
|
||||
private Duration $registryTtl;
|
||||
|
||||
public function __construct(
|
||||
private readonly Cache $cache,
|
||||
?Duration $registryTtl = null,
|
||||
) {
|
||||
$this->registryTtl = $registryTtl ?? Duration::fromHours(24);
|
||||
}
|
||||
|
||||
public function discoverServices(string $namespace): array
|
||||
{
|
||||
$servicesKey = $this->getServicesKey($namespace);
|
||||
$cacheItem = $this->cache->get($servicesKey);
|
||||
|
||||
if (! $cacheItem->isHit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$services = $cacheItem->value;
|
||||
|
||||
return is_array($services) ? $services : [];
|
||||
}
|
||||
|
||||
public function registerService(string $service, string $namespace): void
|
||||
{
|
||||
// Register namespace
|
||||
$this->registerNamespace($namespace);
|
||||
|
||||
// Add service to namespace
|
||||
$servicesKey = $this->getServicesKey($namespace);
|
||||
$services = $this->discoverServices($namespace);
|
||||
|
||||
if (! in_array($service, $services, true)) {
|
||||
$services[] = $service;
|
||||
$this->cache->set($servicesKey, $services, $this->registryTtl);
|
||||
}
|
||||
}
|
||||
|
||||
public function unregisterService(string $service, string $namespace): void
|
||||
{
|
||||
$servicesKey = $this->getServicesKey($namespace);
|
||||
$services = $this->discoverServices($namespace);
|
||||
|
||||
$filteredServices = array_values(array_filter(
|
||||
$services,
|
||||
fn (string $s) => $s !== $service
|
||||
));
|
||||
|
||||
if (empty($filteredServices)) {
|
||||
$this->cache->forget($servicesKey);
|
||||
$this->unregisterNamespaceIfEmpty($namespace);
|
||||
} else {
|
||||
$this->cache->set($servicesKey, $filteredServices, $this->registryTtl);
|
||||
}
|
||||
}
|
||||
|
||||
public function isServiceRegistered(string $service, string $namespace): bool
|
||||
{
|
||||
$services = $this->discoverServices($namespace);
|
||||
|
||||
return in_array($service, $services, true);
|
||||
}
|
||||
|
||||
public function getNamespaces(): array
|
||||
{
|
||||
$namespacesKey = $this->getNamespacesKey();
|
||||
$cacheItem = $this->cache->get($namespacesKey);
|
||||
|
||||
if (! $cacheItem->isHit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$namespaces = $cacheItem->value;
|
||||
|
||||
return is_array($namespaces) ? $namespaces : [];
|
||||
}
|
||||
|
||||
public function getAllServices(): array
|
||||
{
|
||||
$result = [];
|
||||
$namespaces = $this->getNamespaces();
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
$services = $this->discoverServices($namespace);
|
||||
$result[$namespace] = $services;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function clearNamespace(string $namespace): void
|
||||
{
|
||||
$servicesKey = $this->getServicesKey($namespace);
|
||||
$this->cache->forget($servicesKey);
|
||||
$this->unregisterNamespace($namespace);
|
||||
}
|
||||
|
||||
public function clearAll(): void
|
||||
{
|
||||
$namespaces = $this->getNamespaces();
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
$this->clearNamespace($namespace);
|
||||
}
|
||||
|
||||
$namespacesKey = $this->getNamespacesKey();
|
||||
$this->cache->forget($namespacesKey);
|
||||
}
|
||||
|
||||
private function registerNamespace(string $namespace): void
|
||||
{
|
||||
$namespacesKey = $this->getNamespacesKey();
|
||||
$namespaces = $this->getNamespaces();
|
||||
|
||||
if (! in_array($namespace, $namespaces, true)) {
|
||||
$namespaces[] = $namespace;
|
||||
$this->cache->set($namespacesKey, $namespaces, $this->registryTtl);
|
||||
}
|
||||
}
|
||||
|
||||
private function unregisterNamespace(string $namespace): void
|
||||
{
|
||||
$namespacesKey = $this->getNamespacesKey();
|
||||
$namespaces = $this->getNamespaces();
|
||||
|
||||
$filteredNamespaces = array_values(array_filter(
|
||||
$namespaces,
|
||||
fn (string $ns) => $ns !== $namespace
|
||||
));
|
||||
|
||||
if (empty($filteredNamespaces)) {
|
||||
$this->cache->forget($namespacesKey);
|
||||
} else {
|
||||
$this->cache->set($namespacesKey, $filteredNamespaces, $this->registryTtl);
|
||||
}
|
||||
}
|
||||
|
||||
private function unregisterNamespaceIfEmpty(string $namespace): void
|
||||
{
|
||||
$services = $this->discoverServices($namespace);
|
||||
if (empty($services)) {
|
||||
$this->unregisterNamespace($namespace);
|
||||
}
|
||||
}
|
||||
|
||||
private function getNamespacesKey(): CacheKey
|
||||
{
|
||||
return CacheKey::fromString(self::REGISTRY_PREFIX . self::NAMESPACE_KEY);
|
||||
}
|
||||
|
||||
private function getServicesKey(string $namespace): CacheKey
|
||||
{
|
||||
return CacheKey::fromString(self::REGISTRY_PREFIX . $namespace . self::SERVICES_SUFFIX);
|
||||
}
|
||||
}
|
||||
51
src/Framework/CircuitBreaker/Registry/ServiceRegistry.php
Normal file
51
src/Framework/CircuitBreaker/Registry/ServiceRegistry.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\Registry;
|
||||
|
||||
/**
|
||||
* Interface for discovering and managing services in circuit breaker namespaces
|
||||
*/
|
||||
interface ServiceRegistry
|
||||
{
|
||||
/**
|
||||
* Discover all services in a given namespace
|
||||
*/
|
||||
public function discoverServices(string $namespace): array;
|
||||
|
||||
/**
|
||||
* Register a service in a namespace
|
||||
*/
|
||||
public function registerService(string $service, string $namespace): void;
|
||||
|
||||
/**
|
||||
* Unregister a service from a namespace
|
||||
*/
|
||||
public function unregisterService(string $service, string $namespace): void;
|
||||
|
||||
/**
|
||||
* Check if a service is registered in a namespace
|
||||
*/
|
||||
public function isServiceRegistered(string $service, string $namespace): bool;
|
||||
|
||||
/**
|
||||
* Get all registered namespaces
|
||||
*/
|
||||
public function getNamespaces(): array;
|
||||
|
||||
/**
|
||||
* Get all services across all namespaces
|
||||
*/
|
||||
public function getAllServices(): array;
|
||||
|
||||
/**
|
||||
* Clear all registered services in a namespace
|
||||
*/
|
||||
public function clearNamespace(string $namespace): void;
|
||||
|
||||
/**
|
||||
* Clear all registered services
|
||||
*/
|
||||
public function clearAll(): void;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\CircuitBreaker\SlidingWindow;
|
||||
|
||||
use App\Framework\CircuitBreaker\CircuitBreakerConfig;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\SlidingWindow\Aggregator\BooleanResult;
|
||||
use App\Framework\SlidingWindow\SlidingWindow;
|
||||
|
||||
/**
|
||||
* Circuit Breaker specific sliding window wrapper
|
||||
*/
|
||||
final readonly class CircuitBreakerSlidingWindow
|
||||
{
|
||||
public function __construct(
|
||||
private SlidingWindow $slidingWindow,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful operation
|
||||
*/
|
||||
public function recordSuccess(?Timestamp $timestamp = null): void
|
||||
{
|
||||
$timestamp ??= Timestamp::now();
|
||||
$this->slidingWindow->record(true, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed operation
|
||||
*/
|
||||
public function recordFailure(?Timestamp $timestamp = null): void
|
||||
{
|
||||
$timestamp ??= Timestamp::now();
|
||||
$this->slidingWindow->record(false, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if failure threshold is exceeded
|
||||
*/
|
||||
public function isFailureThresholdExceeded(CircuitBreakerConfig $config): bool
|
||||
{
|
||||
$stats = $this->slidingWindow->getStats();
|
||||
$booleanData = $stats->getAggregatedValue('boolean');
|
||||
|
||||
if (! $booleanData instanceof BooleanResult) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check minimum requests threshold
|
||||
if (! $booleanData->hasMinimumRequests($config->successThreshold)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate failure threshold as percentage
|
||||
$failureThreshold = $config->failureThreshold / 100.0; // Convert to 0.0-1.0 range
|
||||
|
||||
return $booleanData->isFailureRateExceeded($failureThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current failure rate
|
||||
*/
|
||||
public function getFailureRate(): float
|
||||
{
|
||||
$stats = $this->slidingWindow->getStats();
|
||||
$booleanData = $stats->getAggregatedValue('boolean');
|
||||
|
||||
if (! $booleanData instanceof BooleanResult) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $booleanData->failureRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success rate
|
||||
*/
|
||||
public function getSuccessRate(): float
|
||||
{
|
||||
$stats = $this->slidingWindow->getStats();
|
||||
$booleanData = $stats->getAggregatedValue('boolean');
|
||||
|
||||
if (! $booleanData instanceof BooleanResult) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $booleanData->successRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total request count in window
|
||||
*/
|
||||
public function getTotalRequests(): int
|
||||
{
|
||||
$stats = $this->slidingWindow->getStats();
|
||||
|
||||
return $stats->totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the sliding window
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->slidingWindow->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying sliding window
|
||||
*/
|
||||
public function getSlidingWindow(): SlidingWindow
|
||||
{
|
||||
return $this->slidingWindow;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user