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:
43
src/Framework/Retry/Events/RetryAttemptEvent.php
Normal file
43
src/Framework/Retry/Events/RetryAttemptEvent.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Event für jeden Retry-Versuch
|
||||
*/
|
||||
final readonly class RetryAttemptEvent
|
||||
{
|
||||
public function __construct(
|
||||
public int $attempt,
|
||||
public int $maxAttempts,
|
||||
public ?Throwable $lastException,
|
||||
public array $context,
|
||||
public Timestamp $timestamp
|
||||
) {
|
||||
}
|
||||
|
||||
public function isFirstAttempt(): bool
|
||||
{
|
||||
return $this->attempt === 1;
|
||||
}
|
||||
|
||||
public function isRetryAttempt(): bool
|
||||
{
|
||||
return $this->attempt > 1;
|
||||
}
|
||||
|
||||
public function isLastAttempt(): bool
|
||||
{
|
||||
return $this->attempt >= $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function getOperationType(): ?string
|
||||
{
|
||||
return $this->context['operation_type'] ?? null;
|
||||
}
|
||||
}
|
||||
46
src/Framework/Retry/Events/RetryFailedEvent.php
Normal file
46
src/Framework/Retry/Events/RetryFailedEvent.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Retry\RetryResult;
|
||||
|
||||
/**
|
||||
* Event für fehlgeschlagene Retry-Operationen
|
||||
*/
|
||||
final readonly class RetryFailedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public RetryResult $result,
|
||||
public array $context,
|
||||
public Timestamp $timestamp
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAttemptCount(): int
|
||||
{
|
||||
return $this->result->totalAttempts;
|
||||
}
|
||||
|
||||
public function getDurationMs(): int
|
||||
{
|
||||
return (int)$this->result->totalDuration->toMilliseconds();
|
||||
}
|
||||
|
||||
public function getLastException(): ?\Throwable
|
||||
{
|
||||
return $this->result->lastException;
|
||||
}
|
||||
|
||||
public function getOperationType(): ?string
|
||||
{
|
||||
return $this->context['operation_type'] ?? null;
|
||||
}
|
||||
|
||||
public function getAttemptHistory(): array
|
||||
{
|
||||
return $this->result->attemptHistory;
|
||||
}
|
||||
}
|
||||
41
src/Framework/Retry/Events/RetrySucceededEvent.php
Normal file
41
src/Framework/Retry/Events/RetrySucceededEvent.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Events;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Retry\RetryResult;
|
||||
|
||||
/**
|
||||
* Event für erfolgreiche Retry-Operationen
|
||||
*/
|
||||
final readonly class RetrySucceededEvent
|
||||
{
|
||||
public function __construct(
|
||||
public RetryResult $result,
|
||||
public array $context,
|
||||
public Timestamp $timestamp
|
||||
) {
|
||||
}
|
||||
|
||||
public function wasRetriedOperation(): bool
|
||||
{
|
||||
return $this->result->totalAttempts > 1;
|
||||
}
|
||||
|
||||
public function getAttemptCount(): int
|
||||
{
|
||||
return $this->result->totalAttempts;
|
||||
}
|
||||
|
||||
public function getDurationMs(): int
|
||||
{
|
||||
return (int)$this->result->totalDuration->toMilliseconds();
|
||||
}
|
||||
|
||||
public function getOperationType(): ?string
|
||||
{
|
||||
return $this->context['operation_type'] ?? null;
|
||||
}
|
||||
}
|
||||
182
src/Framework/Retry/Metrics/RetryMetrics.php
Normal file
182
src/Framework/Retry/Metrics/RetryMetrics.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Metrics;
|
||||
|
||||
use App\Framework\Core\Events\OnEvent;
|
||||
use App\Framework\Retry\Events\RetryAttemptEvent;
|
||||
use App\Framework\Retry\Events\RetryFailedEvent;
|
||||
use App\Framework\Retry\Events\RetrySucceededEvent;
|
||||
|
||||
/**
|
||||
* Sammelt Metriken für Retry-Operationen
|
||||
*/
|
||||
final class RetryMetrics
|
||||
{
|
||||
private array $stats = [
|
||||
'total_operations' => 0,
|
||||
'succeeded_operations' => 0,
|
||||
'failed_operations' => 0,
|
||||
'total_attempts' => 0,
|
||||
'retry_rate' => 0.0,
|
||||
'success_after_retry_rate' => 0.0,
|
||||
'by_operation_type' => [],
|
||||
'by_exception_type' => [],
|
||||
'avg_attempts_per_operation' => 0.0,
|
||||
'avg_duration_ms' => 0.0,
|
||||
];
|
||||
|
||||
#[OnEvent]
|
||||
public function onRetryAttempt(RetryAttemptEvent $event): void
|
||||
{
|
||||
$this->stats['total_attempts']++;
|
||||
|
||||
$operationType = $event->getOperationType() ?? 'unknown';
|
||||
if (! isset($this->stats['by_operation_type'][$operationType])) {
|
||||
$this->stats['by_operation_type'][$operationType] = [
|
||||
'attempts' => 0,
|
||||
'operations' => 0,
|
||||
'successes' => 0,
|
||||
'failures' => 0,
|
||||
];
|
||||
}
|
||||
$this->stats['by_operation_type'][$operationType]['attempts']++;
|
||||
|
||||
// Exception-Statistiken
|
||||
if ($event->lastException !== null) {
|
||||
$exceptionType = $event->lastException::class;
|
||||
if (! isset($this->stats['by_exception_type'][$exceptionType])) {
|
||||
$this->stats['by_exception_type'][$exceptionType] = 0;
|
||||
}
|
||||
$this->stats['by_exception_type'][$exceptionType]++;
|
||||
}
|
||||
}
|
||||
|
||||
#[OnEvent]
|
||||
public function onRetrySucceeded(RetrySucceededEvent $event): void
|
||||
{
|
||||
$this->stats['total_operations']++;
|
||||
$this->stats['succeeded_operations']++;
|
||||
|
||||
$operationType = $event->getOperationType() ?? 'unknown';
|
||||
if (! isset($this->stats['by_operation_type'][$operationType])) {
|
||||
$this->stats['by_operation_type'][$operationType] = [
|
||||
'attempts' => 0,
|
||||
'operations' => 0,
|
||||
'successes' => 0,
|
||||
'failures' => 0,
|
||||
];
|
||||
}
|
||||
$this->stats['by_operation_type'][$operationType]['operations']++;
|
||||
$this->stats['by_operation_type'][$operationType]['successes']++;
|
||||
|
||||
$this->updateCalculatedStats();
|
||||
}
|
||||
|
||||
#[OnEvent]
|
||||
public function onRetryFailed(RetryFailedEvent $event): void
|
||||
{
|
||||
$this->stats['total_operations']++;
|
||||
$this->stats['failed_operations']++;
|
||||
|
||||
$operationType = $event->getOperationType() ?? 'unknown';
|
||||
if (! isset($this->stats['by_operation_type'][$operationType])) {
|
||||
$this->stats['by_operation_type'][$operationType] = [
|
||||
'attempts' => 0,
|
||||
'operations' => 0,
|
||||
'successes' => 0,
|
||||
'failures' => 0,
|
||||
];
|
||||
}
|
||||
$this->stats['by_operation_type'][$operationType]['operations']++;
|
||||
$this->stats['by_operation_type'][$operationType]['failures']++;
|
||||
|
||||
$this->updateCalculatedStats();
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
return $this->stats;
|
||||
}
|
||||
|
||||
public function getSuccessRate(): float
|
||||
{
|
||||
if ($this->stats['total_operations'] === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $this->stats['succeeded_operations'] / $this->stats['total_operations'];
|
||||
}
|
||||
|
||||
public function getRetryRate(): float
|
||||
{
|
||||
return $this->stats['retry_rate'];
|
||||
}
|
||||
|
||||
public function getAverageAttemptsPerOperation(): float
|
||||
{
|
||||
return $this->stats['avg_attempts_per_operation'];
|
||||
}
|
||||
|
||||
public function getMostRetriedOperations(): array
|
||||
{
|
||||
$operations = $this->stats['by_operation_type'];
|
||||
|
||||
uasort($operations, function ($a, $b) {
|
||||
return $b['attempts'] <=> $a['attempts'];
|
||||
});
|
||||
|
||||
return array_slice($operations, 0, 5, true);
|
||||
}
|
||||
|
||||
public function getMostCommonExceptions(): array
|
||||
{
|
||||
arsort($this->stats['by_exception_type']);
|
||||
|
||||
return array_slice($this->stats['by_exception_type'], 0, 5, true);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->stats = [
|
||||
'total_operations' => 0,
|
||||
'succeeded_operations' => 0,
|
||||
'failed_operations' => 0,
|
||||
'total_attempts' => 0,
|
||||
'retry_rate' => 0.0,
|
||||
'success_after_retry_rate' => 0.0,
|
||||
'by_operation_type' => [],
|
||||
'by_exception_type' => [],
|
||||
'avg_attempts_per_operation' => 0.0,
|
||||
'avg_duration_ms' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
private function updateCalculatedStats(): void
|
||||
{
|
||||
// Retry-Rate: Wie viele Operationen wurden wiederholt?
|
||||
$operationsWithRetry = 0;
|
||||
foreach ($this->stats['by_operation_type'] as $stats) {
|
||||
if ($stats['attempts'] > $stats['operations']) {
|
||||
$operationsWithRetry += $stats['operations'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->stats['total_operations'] > 0) {
|
||||
$this->stats['retry_rate'] = $operationsWithRetry / $this->stats['total_operations'];
|
||||
$this->stats['avg_attempts_per_operation'] = $this->stats['total_attempts'] / $this->stats['total_operations'];
|
||||
}
|
||||
|
||||
// Success-After-Retry-Rate
|
||||
if ($operationsWithRetry > 0) {
|
||||
$successAfterRetry = 0;
|
||||
foreach ($this->stats['by_operation_type'] as $stats) {
|
||||
if ($stats['attempts'] > $stats['operations']) {
|
||||
$successAfterRetry += $stats['successes'];
|
||||
}
|
||||
}
|
||||
$this->stats['success_after_retry_rate'] = $successAfterRetry / $operationsWithRetry;
|
||||
}
|
||||
}
|
||||
}
|
||||
288
src/Framework/Retry/README.md
Normal file
288
src/Framework/Retry/README.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Retry Framework
|
||||
|
||||
Unified retry system for the PHP framework providing consistent retry logic across all components.
|
||||
|
||||
## Overview
|
||||
|
||||
The Retry Framework consolidates retry logic from various parts of the system (Database, HttpClient, Cache, etc.) into a single, configurable, and observable system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```php
|
||||
use App\Framework\Retry\RetryManager;
|
||||
|
||||
// Simple retry with exponential backoff
|
||||
$retryManager = RetryManager::create($clock)
|
||||
->exponentialBackoff(maxAttempts: 3, initialDelayMs: 100);
|
||||
|
||||
$result = $retryManager->execute(function() {
|
||||
// Your operation that might fail
|
||||
return $this->unreliableApiCall();
|
||||
});
|
||||
|
||||
// Get the result (throws exception if all retries failed)
|
||||
$data = $result->getResult();
|
||||
```
|
||||
|
||||
### Fluent API Examples
|
||||
|
||||
```php
|
||||
// Linear delay strategy
|
||||
$result = RetryManager::create($clock)
|
||||
->linearDelay(maxAttempts: 5, delayMs: 500)
|
||||
->execute($operation);
|
||||
|
||||
// Fixed retry (no delay)
|
||||
$result = RetryManager::create($clock)
|
||||
->fixedRetry(maxAttempts: 2)
|
||||
->execute($operation);
|
||||
|
||||
// Custom exponential backoff
|
||||
$result = RetryManager::create($clock)
|
||||
->exponentialBackoff(
|
||||
maxAttempts: 4,
|
||||
initialDelayMs: 50,
|
||||
multiplier: 3.0
|
||||
)
|
||||
->execute($operation);
|
||||
```
|
||||
|
||||
### Pre-configured Scenarios
|
||||
|
||||
```php
|
||||
// Database operations
|
||||
$result = $retryManager->executeDatabaseOperation(function() {
|
||||
return $this->database->query('SELECT * FROM users');
|
||||
});
|
||||
|
||||
// HTTP requests
|
||||
$result = $retryManager->executeHttpRequest(function() {
|
||||
return $this->httpClient->get('https://api.example.com/data');
|
||||
});
|
||||
|
||||
// Cache operations
|
||||
$result = $retryManager->executeCacheOperation(function() {
|
||||
return $this->cache->get('expensive-computation');
|
||||
});
|
||||
```
|
||||
|
||||
## Strategies
|
||||
|
||||
### ExponentialBackoffStrategy
|
||||
|
||||
Doubles the delay between retries: 100ms → 200ms → 400ms → 800ms
|
||||
|
||||
```php
|
||||
use App\Framework\Retry\Strategies\ExponentialBackoffStrategy;
|
||||
|
||||
// Custom strategy
|
||||
$strategy = new ExponentialBackoffStrategy(
|
||||
maxAttempts: 3,
|
||||
initialDelay: Duration::fromMilliseconds(100),
|
||||
multiplier: 2.0,
|
||||
maxDelay: Duration::fromSeconds(10),
|
||||
useJitter: true
|
||||
);
|
||||
|
||||
$retryManager = RetryManager::create($clock)->withStrategy($strategy);
|
||||
```
|
||||
|
||||
**Pre-configured factories:**
|
||||
- `ExponentialBackoffStrategy::forDatabase()` - Optimized for database operations
|
||||
- `ExponentialBackoffStrategy::forHttpClient()` - Optimized for HTTP requests
|
||||
- `ExponentialBackoffStrategy::forCache()` - Optimized for cache operations
|
||||
|
||||
### LinearDelayStrategy
|
||||
|
||||
Constant delay between retries: 500ms → 500ms → 500ms
|
||||
|
||||
```php
|
||||
use App\Framework\Retry\Strategies\LinearDelayStrategy;
|
||||
|
||||
$strategy = LinearDelayStrategy::medium(3); // 500ms delay, 3 attempts
|
||||
$retryManager = RetryManager::create($clock)->withStrategy($strategy);
|
||||
```
|
||||
|
||||
**Pre-configured factories:**
|
||||
- `LinearDelayStrategy::fast()` - 100ms delay
|
||||
- `LinearDelayStrategy::medium()` - 500ms delay
|
||||
- `LinearDelayStrategy::slow()` - 2s delay
|
||||
|
||||
### FixedRetryStrategy
|
||||
|
||||
No delay between retries (immediate retry).
|
||||
|
||||
```php
|
||||
use App\Framework\Retry\Strategies\FixedRetryStrategy;
|
||||
|
||||
$strategy = FixedRetryStrategy::quick(2); // 2 attempts, no delay
|
||||
$retryManager = RetryManager::create($clock)->withStrategy($strategy);
|
||||
```
|
||||
|
||||
## Event System Integration
|
||||
|
||||
The retry system emits events for monitoring and observability:
|
||||
|
||||
```php
|
||||
// Enable events
|
||||
$retryManager = RetryManager::create($clock)
|
||||
->withEventDispatcher($eventDispatcher)
|
||||
->withContext(['service' => 'user-api']);
|
||||
|
||||
// Events are automatically dispatched:
|
||||
// - RetryAttemptEvent: For each attempt
|
||||
// - RetrySucceededEvent: When operation succeeds
|
||||
// - RetryFailedEvent: When all retries are exhausted
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
```php
|
||||
use App\Framework\Core\Events\OnEvent;
|
||||
use App\Framework\Retry\Events\RetryFailedEvent;
|
||||
|
||||
class RetryMonitoring
|
||||
{
|
||||
#[OnEvent]
|
||||
public function onRetryFailed(RetryFailedEvent $event): void
|
||||
{
|
||||
$this->logger->error('Retry operation failed', [
|
||||
'attempts' => $event->getAttemptCount(),
|
||||
'duration_ms' => $event->getDurationMs(),
|
||||
'operation_type' => $event->getOperationType(),
|
||||
'exception' => $event->getLastException()?->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Metrics and Monitoring
|
||||
|
||||
The system includes built-in metrics collection:
|
||||
|
||||
```php
|
||||
use App\Framework\Retry\Metrics\RetryMetrics;
|
||||
|
||||
// RetryMetrics automatically collects data via events
|
||||
$metrics = $container->get(RetryMetrics::class);
|
||||
|
||||
// Get statistics
|
||||
$stats = $metrics->getStats();
|
||||
echo "Success rate: " . $metrics->getSuccessRate() * 100 . "%\n";
|
||||
echo "Retry rate: " . $metrics->getRetryRate() * 100 . "%\n";
|
||||
echo "Avg attempts: " . $metrics->getAverageAttemptsPerOperation() . "\n";
|
||||
|
||||
// Most problematic operations
|
||||
$mostRetried = $metrics->getMostRetriedOperations();
|
||||
$commonExceptions = $metrics->getMostCommonExceptions();
|
||||
```
|
||||
|
||||
## Migration from Legacy Middleware
|
||||
|
||||
### Database RetryMiddleware
|
||||
|
||||
**Before:**
|
||||
```php
|
||||
use App\Framework\Database\Middleware\RetryMiddleware;
|
||||
|
||||
$middleware = new RetryMiddleware($timer, maxRetries: 3, retryDelayMs: 100);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```php
|
||||
use App\Framework\Database\Middleware\UnifiedRetryMiddleware;
|
||||
|
||||
$middleware = new UnifiedRetryMiddleware($clock, $eventDispatcher);
|
||||
```
|
||||
|
||||
### HttpClient RetryMiddleware
|
||||
|
||||
**Before:**
|
||||
```php
|
||||
use App\Framework\HttpClient\Middleware\RetryMiddleware;
|
||||
|
||||
$middleware = new RetryMiddleware($timer, maxRetries: 3, baseDelay: 1.0);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```php
|
||||
use App\Framework\HttpClient\Middleware\UnifiedRetryMiddleware;
|
||||
|
||||
$middleware = UnifiedRetryMiddleware::forApi($clock, $eventDispatcher);
|
||||
```
|
||||
|
||||
## RetryableOperation Interface
|
||||
|
||||
For more complex scenarios, implement the `RetryableOperation` interface:
|
||||
|
||||
```php
|
||||
use App\Framework\Retry\RetryableOperation;
|
||||
|
||||
class DatabaseBackupOperation implements RetryableOperation
|
||||
{
|
||||
public function execute(): mixed
|
||||
{
|
||||
return $this->performBackup();
|
||||
}
|
||||
|
||||
public function canRetry(Throwable $exception): bool
|
||||
{
|
||||
// Don't retry on authentication errors
|
||||
return !($exception instanceof AuthenticationException);
|
||||
}
|
||||
|
||||
public function prepareRetry(int $attempt, Throwable $lastException): void
|
||||
{
|
||||
// Clean up before retry
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
$operation = new DatabaseBackupOperation();
|
||||
$result = $retryManager->executeOperation($operation);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The retry system integrates with the framework's dependency injection:
|
||||
|
||||
```php
|
||||
// In your service provider or initializer
|
||||
$container->bind(RetryManager::class, function($container) {
|
||||
return RetryManager::create($container->get(Clock::class))
|
||||
->withEventDispatcher($container->get(EventDispatcherInterface::class));
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Choose appropriate strategies**: Use exponential backoff for external services, linear delay for predictable services
|
||||
2. **Set reasonable limits**: Don't retry indefinitely, set max attempts and timeouts
|
||||
3. **Monitor and alert**: Use the event system to monitor retry patterns and failures
|
||||
4. **Consider circuit breakers**: For external services, combine with circuit breaker pattern
|
||||
5. **Add context**: Use `withContext()` to add meaningful metadata for debugging
|
||||
|
||||
## Error Handling
|
||||
|
||||
```php
|
||||
$result = $retryManager->execute($operation);
|
||||
|
||||
if ($result->wasSuccessful()) {
|
||||
$data = $result->getResult();
|
||||
echo "Success after {$result->getAttemptCount()} attempts\n";
|
||||
} else {
|
||||
echo "Failed after {$result->getAttemptCount()} attempts\n";
|
||||
echo "Total duration: {$result->getTotalDuration()->toSeconds()}s\n";
|
||||
throw $result->lastException;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Jitter**: Exponential backoff includes jitter by default to prevent thundering herd
|
||||
- **Memory**: Retry history is kept in memory for the duration of the operation
|
||||
- **Events**: Event dispatching adds minimal overhead; disable if not needed
|
||||
- **Strategies**: Fixed retry has the lowest overhead, exponential backoff the highest
|
||||
29
src/Framework/Retry/RetryInitializer.php
Normal file
29
src/Framework/Retry/RetryInitializer.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry;
|
||||
|
||||
use App\Framework\Core\Events\EventDispatcherInterface;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\Initializer;
|
||||
|
||||
/**
|
||||
* Initializer für das Retry-System
|
||||
*
|
||||
* Registriert RetryManager im DI-Container
|
||||
*/
|
||||
#[Initializer]
|
||||
final readonly class RetryInitializer
|
||||
{
|
||||
public function __invoke(Clock $clock, ?EventDispatcherInterface $eventDispatcher = null): RetryManager
|
||||
{
|
||||
$retryManager = RetryManager::create($clock);
|
||||
|
||||
if ($eventDispatcher !== null) {
|
||||
$retryManager = $retryManager->withEventDispatcher($eventDispatcher);
|
||||
}
|
||||
|
||||
return $retryManager;
|
||||
}
|
||||
}
|
||||
251
src/Framework/Retry/RetryManager.php
Normal file
251
src/Framework/Retry/RetryManager.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry;
|
||||
|
||||
use App\Framework\Core\Events\EventDispatcherInterface;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Retry\Events\RetryAttemptEvent;
|
||||
use App\Framework\Retry\Events\RetryFailedEvent;
|
||||
use App\Framework\Retry\Events\RetrySucceededEvent;
|
||||
use App\Framework\Retry\Strategies\ExponentialBackoffStrategy;
|
||||
use App\Framework\Retry\Strategies\FixedRetryStrategy;
|
||||
use App\Framework\Retry\Strategies\LinearDelayStrategy;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Zentraler Retry-Manager mit Fluent API
|
||||
*
|
||||
* Bietet eine einheitliche Schnittstelle für alle Retry-Operationen
|
||||
*/
|
||||
final class RetryManager
|
||||
{
|
||||
private RetryStrategy $strategy;
|
||||
|
||||
private ?EventDispatcherInterface $eventDispatcher = null;
|
||||
|
||||
private array $context = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly Clock $clock,
|
||||
?RetryStrategy $strategy = null
|
||||
) {
|
||||
$this->strategy = $strategy ?? new FixedRetryStrategy(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-Methoden für fluent API
|
||||
*/
|
||||
public static function create(Clock $clock): self
|
||||
{
|
||||
return new self($clock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fluent API für Strategien
|
||||
*/
|
||||
public function withStrategy(RetryStrategy $strategy): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->strategy = $strategy;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
public function exponentialBackoff(
|
||||
int $maxAttempts = 3,
|
||||
int $initialDelayMs = 100,
|
||||
float $multiplier = 2.0
|
||||
): self {
|
||||
return $this->withStrategy(new ExponentialBackoffStrategy(
|
||||
maxAttempts: $maxAttempts,
|
||||
initialDelay: Duration::fromMilliseconds($initialDelayMs),
|
||||
multiplier: $multiplier
|
||||
));
|
||||
}
|
||||
|
||||
public function linearDelay(int $maxAttempts = 3, int $delayMs = 500): self
|
||||
{
|
||||
return $this->withStrategy(new LinearDelayStrategy(
|
||||
maxAttempts: $maxAttempts,
|
||||
delay: Duration::fromMilliseconds($delayMs)
|
||||
));
|
||||
}
|
||||
|
||||
public function fixedRetry(int $maxAttempts = 3): self
|
||||
{
|
||||
return $this->withStrategy(new FixedRetryStrategy($maxAttempts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-System Integration
|
||||
*/
|
||||
public function withEventDispatcher(EventDispatcherInterface $dispatcher): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->eventDispatcher = $dispatcher;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
public function withContext(array $context): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->context = array_merge($this->context, $context);
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauptmethode: Führt Operation mit Retry aus
|
||||
*/
|
||||
public function execute(callable $operation): RetryResult
|
||||
{
|
||||
$startTime = $this->clock->time();
|
||||
$attemptHistory = [];
|
||||
$currentAttempt = 0;
|
||||
$lastException = null;
|
||||
|
||||
while (true) {
|
||||
$currentAttempt++;
|
||||
$attemptStart = $this->clock->time();
|
||||
|
||||
try {
|
||||
// Event für Versuch
|
||||
$this->dispatchAttemptEvent($currentAttempt, $lastException);
|
||||
|
||||
// Operation ausführen
|
||||
$result = $operation();
|
||||
|
||||
// Erfolg!
|
||||
$totalDuration = Duration::between($startTime, $this->clock->time());
|
||||
$retryResult = RetryResult::success(
|
||||
result: $result,
|
||||
attempts: $currentAttempt,
|
||||
duration: $totalDuration,
|
||||
history: $attemptHistory
|
||||
);
|
||||
|
||||
$this->dispatchSuccessEvent($retryResult);
|
||||
|
||||
return $retryResult;
|
||||
|
||||
} catch (Throwable $exception) {
|
||||
$attemptDuration = Duration::between($attemptStart, $this->clock->time());
|
||||
$attemptHistory[] = [
|
||||
'attempt' => $currentAttempt,
|
||||
'exception' => $exception::class,
|
||||
'message' => $exception->getMessage(),
|
||||
'duration' => $attemptDuration->toMilliseconds(),
|
||||
];
|
||||
|
||||
$lastException = $exception;
|
||||
|
||||
// Prüfen ob Retry möglich
|
||||
if (! $this->strategy->shouldRetry($currentAttempt, $exception)) {
|
||||
// Keine weiteren Versuche
|
||||
$totalDuration = Duration::between($startTime, $this->clock->time());
|
||||
$retryResult = RetryResult::failure(
|
||||
exception: $exception,
|
||||
attempts: $currentAttempt,
|
||||
duration: $totalDuration,
|
||||
history: $attemptHistory
|
||||
);
|
||||
|
||||
$this->dispatchFailureEvent($retryResult);
|
||||
|
||||
return $retryResult;
|
||||
}
|
||||
|
||||
// Delay vor nächstem Versuch
|
||||
$delay = $this->strategy->getDelay($currentAttempt, $exception);
|
||||
if (! $delay->isZero()) {
|
||||
usleep((int)$delay->toMicroseconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RetryableOperation Interface Support
|
||||
*/
|
||||
public function executeOperation(RetryableOperation $operation): RetryResult
|
||||
{
|
||||
return $this->execute(function () use ($operation) {
|
||||
return $operation->execute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience-Methoden für häufige Szenarien
|
||||
*/
|
||||
public function executeDatabaseOperation(callable $operation): RetryResult
|
||||
{
|
||||
return $this
|
||||
->withStrategy(ExponentialBackoffStrategy::forDatabase())
|
||||
->withContext(['operation_type' => 'database'])
|
||||
->execute($operation);
|
||||
}
|
||||
|
||||
public function executeHttpRequest(callable $operation): RetryResult
|
||||
{
|
||||
return $this
|
||||
->withStrategy(ExponentialBackoffStrategy::forHttpClient())
|
||||
->withContext(['operation_type' => 'http'])
|
||||
->execute($operation);
|
||||
}
|
||||
|
||||
public function executeCacheOperation(callable $operation): RetryResult
|
||||
{
|
||||
return $this
|
||||
->withStrategy(ExponentialBackoffStrategy::forCache())
|
||||
->withContext(['operation_type' => 'cache'])
|
||||
->execute($operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-Dispatching
|
||||
*/
|
||||
private function dispatchAttemptEvent(int $attempt, ?Throwable $lastException): void
|
||||
{
|
||||
if ($this->eventDispatcher === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new RetryAttemptEvent(
|
||||
attempt: $attempt,
|
||||
maxAttempts: $this->strategy->getMaxAttempts(),
|
||||
lastException: $lastException,
|
||||
context: $this->context,
|
||||
timestamp: $this->clock->time()
|
||||
));
|
||||
}
|
||||
|
||||
private function dispatchSuccessEvent(RetryResult $result): void
|
||||
{
|
||||
if ($this->eventDispatcher === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new RetrySucceededEvent(
|
||||
result: $result,
|
||||
context: $this->context,
|
||||
timestamp: $this->clock->time()
|
||||
));
|
||||
}
|
||||
|
||||
private function dispatchFailureEvent(RetryResult $result): void
|
||||
{
|
||||
if ($this->eventDispatcher === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new RetryFailedEvent(
|
||||
result: $result,
|
||||
context: $this->context,
|
||||
timestamp: $this->clock->time()
|
||||
));
|
||||
}
|
||||
}
|
||||
79
src/Framework/Retry/RetryResult.php
Normal file
79
src/Framework/Retry/RetryResult.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Ergebnis einer Retry-Operation mit Metadaten
|
||||
*/
|
||||
final readonly class RetryResult
|
||||
{
|
||||
public function __construct(
|
||||
public mixed $result,
|
||||
public bool $succeeded,
|
||||
public int $totalAttempts,
|
||||
public Duration $totalDuration,
|
||||
public ?Throwable $lastException = null,
|
||||
public array $attemptHistory = []
|
||||
) {
|
||||
}
|
||||
|
||||
public static function success(
|
||||
mixed $result,
|
||||
int $attempts,
|
||||
Duration $duration,
|
||||
array $history = []
|
||||
): self {
|
||||
return new self(
|
||||
result: $result,
|
||||
succeeded: true,
|
||||
totalAttempts: $attempts,
|
||||
totalDuration: $duration,
|
||||
attemptHistory: $history
|
||||
);
|
||||
}
|
||||
|
||||
public static function failure(
|
||||
Throwable $exception,
|
||||
int $attempts,
|
||||
Duration $duration,
|
||||
array $history = []
|
||||
): self {
|
||||
return new self(
|
||||
result: null,
|
||||
succeeded: false,
|
||||
totalAttempts: $attempts,
|
||||
totalDuration: $duration,
|
||||
lastException: $exception,
|
||||
attemptHistory: $history
|
||||
);
|
||||
}
|
||||
|
||||
public function getResult(): mixed
|
||||
{
|
||||
if (! $this->succeeded) {
|
||||
throw $this->lastException ?? new \RuntimeException('Operation failed without exception');
|
||||
}
|
||||
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
public function wasSuccessful(): bool
|
||||
{
|
||||
return $this->succeeded;
|
||||
}
|
||||
|
||||
public function getAttemptCount(): int
|
||||
{
|
||||
return $this->totalAttempts;
|
||||
}
|
||||
|
||||
public function getTotalDuration(): Duration
|
||||
{
|
||||
return $this->totalDuration;
|
||||
}
|
||||
}
|
||||
36
src/Framework/Retry/RetryStrategy.php
Normal file
36
src/Framework/Retry/RetryStrategy.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Interface für Retry-Strategien
|
||||
*
|
||||
* Definiert wie und wann Operationen wiederholt werden sollen
|
||||
*/
|
||||
interface RetryStrategy
|
||||
{
|
||||
/**
|
||||
* Entscheidet ob ein weiterer Retry-Versuch gemacht werden soll
|
||||
*/
|
||||
public function shouldRetry(int $currentAttempt, Throwable $exception): bool;
|
||||
|
||||
/**
|
||||
* Bestimmt die Wartezeit vor dem nächsten Versuch
|
||||
*/
|
||||
public function getDelay(int $currentAttempt, Throwable $exception): Duration;
|
||||
|
||||
/**
|
||||
* Maximale Anzahl von Versuchen
|
||||
*/
|
||||
public function getMaxAttempts(): int;
|
||||
|
||||
/**
|
||||
* Prüft ob diese Exception retry-bar ist
|
||||
*/
|
||||
public function isRetryableException(Throwable $exception): bool;
|
||||
}
|
||||
30
src/Framework/Retry/RetryableOperation.php
Normal file
30
src/Framework/Retry/RetryableOperation.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Interface für retry-bare Operationen
|
||||
*/
|
||||
interface RetryableOperation
|
||||
{
|
||||
/**
|
||||
* Führt die Operation aus
|
||||
*
|
||||
* @throws Throwable Wenn die Operation fehlschlägt
|
||||
*/
|
||||
public function execute(): mixed;
|
||||
|
||||
/**
|
||||
* Prüft ob die Operation bei diesem Fehler wiederholt werden kann
|
||||
*/
|
||||
public function canRetry(Throwable $exception): bool;
|
||||
|
||||
/**
|
||||
* Optional: Bereitet die Operation für einen Retry vor
|
||||
*/
|
||||
public function prepareRetry(int $attempt, Throwable $lastException): void;
|
||||
}
|
||||
124
src/Framework/Retry/Strategies/ExponentialBackoffStrategy.php
Normal file
124
src/Framework/Retry/Strategies/ExponentialBackoffStrategy.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Strategies;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exponential Backoff Retry-Strategie
|
||||
*
|
||||
* Verdoppelt die Wartezeit bei jedem Versuch: 100ms -> 200ms -> 400ms -> 800ms
|
||||
* Mit optionalem Jitter zur Vermeidung von Thundering Herd
|
||||
*/
|
||||
final readonly class ExponentialBackoffStrategy implements RetryStrategy
|
||||
{
|
||||
public function __construct(
|
||||
private int $maxAttempts = 3,
|
||||
private Duration $initialDelay = new Duration(100), // 100ms
|
||||
private float $multiplier = 2.0,
|
||||
private Duration $maxDelay = new Duration(10000), // 10s
|
||||
private bool $useJitter = true,
|
||||
private array $retryableExceptions = [
|
||||
\RuntimeException::class,
|
||||
\Exception::class,
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldRetry(int $currentAttempt, Throwable $exception): bool
|
||||
{
|
||||
if ($currentAttempt >= $this->maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isRetryableException($exception);
|
||||
}
|
||||
|
||||
public function getDelay(int $currentAttempt, Throwable $exception): Duration
|
||||
{
|
||||
if ($currentAttempt === 0) {
|
||||
return Duration::zero();
|
||||
}
|
||||
|
||||
// Exponential: delay = initial * (multiplier ^ (attempt - 1))
|
||||
$delay = $this->initialDelay->toMilliseconds() *
|
||||
pow($this->multiplier, $currentAttempt - 1);
|
||||
|
||||
// Maximal-Grenze
|
||||
$delay = min($delay, $this->maxDelay->toMilliseconds());
|
||||
|
||||
// Jitter hinzufügen (±25% Varianz)
|
||||
if ($this->useJitter) {
|
||||
$jitter = (int)($delay * 0.25);
|
||||
$delay = $delay + (random_int(-$jitter, $jitter));
|
||||
}
|
||||
|
||||
return Duration::fromMilliseconds((int) $delay);
|
||||
}
|
||||
|
||||
public function getMaxAttempts(): int
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function isRetryableException(Throwable $exception): bool
|
||||
{
|
||||
foreach ($this->retryableExceptions as $retryableClass) {
|
||||
if ($exception instanceof $retryableClass) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory Methods für häufige Szenarien
|
||||
*/
|
||||
public static function forDatabase(int $maxAttempts = 3): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: $maxAttempts,
|
||||
initialDelay: Duration::fromMilliseconds(50),
|
||||
multiplier: 2.0,
|
||||
maxDelay: Duration::fromSeconds(5),
|
||||
retryableExceptions: [
|
||||
\PDOException::class,
|
||||
'App\Framework\Database\Exception\DatabaseException',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static function forHttpClient(int $maxAttempts = 3): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: $maxAttempts,
|
||||
initialDelay: Duration::fromMilliseconds(200),
|
||||
multiplier: 2.0,
|
||||
maxDelay: Duration::fromSeconds(10),
|
||||
retryableExceptions: [
|
||||
'App\Framework\HttpClient\Exception\HttpClientException',
|
||||
'App\Framework\HttpClient\Exception\CurlExecutionFailed',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public static function forCache(int $maxAttempts = 2): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: $maxAttempts,
|
||||
initialDelay: Duration::fromMilliseconds(10),
|
||||
multiplier: 3.0,
|
||||
maxDelay: Duration::fromMilliseconds(1000),
|
||||
useJitter: false, // Cache sollte schnell sein
|
||||
retryableExceptions: [
|
||||
\RedisException::class,
|
||||
\RuntimeException::class,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/Framework/Retry/Strategies/FixedRetryStrategy.php
Normal file
76
src/Framework/Retry/Strategies/FixedRetryStrategy.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Strategies;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Fixed Retry-Strategie ohne Delay
|
||||
*
|
||||
* Versucht sofort erneut, ohne Wartezeit
|
||||
* Gut für schnelle In-Memory-Operationen
|
||||
*/
|
||||
final readonly class FixedRetryStrategy implements RetryStrategy
|
||||
{
|
||||
public function __construct(
|
||||
private int $maxAttempts = 3,
|
||||
private array $retryableExceptions = [
|
||||
\RuntimeException::class,
|
||||
\Exception::class,
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldRetry(int $currentAttempt, Throwable $exception): bool
|
||||
{
|
||||
if ($currentAttempt >= $this->maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isRetryableException($exception);
|
||||
}
|
||||
|
||||
public function getDelay(int $currentAttempt, Throwable $exception): Duration
|
||||
{
|
||||
return Duration::zero(); // Kein Delay
|
||||
}
|
||||
|
||||
public function getMaxAttempts(): int
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function isRetryableException(Throwable $exception): bool
|
||||
{
|
||||
foreach ($this->retryableExceptions as $retryableClass) {
|
||||
if ($exception instanceof $retryableClass) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory für häufige Szenarien
|
||||
*/
|
||||
public static function quick(int $maxAttempts = 2): self
|
||||
{
|
||||
return new self(maxAttempts: $maxAttempts);
|
||||
}
|
||||
|
||||
public static function forInMemoryOperations(): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: 3,
|
||||
retryableExceptions: [
|
||||
\RuntimeException::class,
|
||||
\LogicException::class,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/Framework/Retry/Strategies/LinearDelayStrategy.php
Normal file
89
src/Framework/Retry/Strategies/LinearDelayStrategy.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Retry\Strategies;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Retry\RetryStrategy;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Linear Delay Retry-Strategie
|
||||
*
|
||||
* Konstante Wartezeit zwischen Versuchen: 500ms -> 500ms -> 500ms
|
||||
* Gut für Services mit vorhersagbaren Recovery-Zeiten
|
||||
*/
|
||||
final readonly class LinearDelayStrategy implements RetryStrategy
|
||||
{
|
||||
public function __construct(
|
||||
private int $maxAttempts = 3,
|
||||
private Duration $delay = new Duration(500),
|
||||
private array $retryableExceptions = [
|
||||
\RuntimeException::class,
|
||||
\Exception::class,
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function shouldRetry(int $currentAttempt, Throwable $exception): bool
|
||||
{
|
||||
if ($currentAttempt >= $this->maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->isRetryableException($exception);
|
||||
}
|
||||
|
||||
public function getDelay(int $currentAttempt, Throwable $exception): Duration
|
||||
{
|
||||
if ($currentAttempt === 0) {
|
||||
return Duration::zero();
|
||||
}
|
||||
|
||||
return $this->delay;
|
||||
}
|
||||
|
||||
public function getMaxAttempts(): int
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function isRetryableException(Throwable $exception): bool
|
||||
{
|
||||
foreach ($this->retryableExceptions as $retryableClass) {
|
||||
if ($exception instanceof $retryableClass) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory Methods
|
||||
*/
|
||||
public static function fast(int $maxAttempts = 3): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: $maxAttempts,
|
||||
delay: Duration::fromMilliseconds(100)
|
||||
);
|
||||
}
|
||||
|
||||
public static function medium(int $maxAttempts = 3): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: $maxAttempts,
|
||||
delay: Duration::fromMilliseconds(500)
|
||||
);
|
||||
}
|
||||
|
||||
public static function slow(int $maxAttempts = 3): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: $maxAttempts,
|
||||
delay: Duration::fromSeconds(2)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user