diff --git a/config/integrations.php b/config/integrations.php index 8cb1fa5..bf1be38 100644 --- a/config/integrations.php +++ b/config/integrations.php @@ -1,13 +1,30 @@ - env('INTEGRATIONS_BASE_URI', null), - 'timeout' => env('INTEGRATIONS_TIMEOUT', null), - 'retry' => [ - 'times' => env('INTEGRATIONS_RETRY_TIMES', 0), - 'sleep_ms' => env('INTEGRATIONS_RETRY_SLEEP_MS', 0), - ], - 'default_headers' => [], - 'logging' => [ - 'channel' => env('INTEGRATIONS_LOG_CHANNEL', null), - ], -]; + env('INTEGRATIONS_BASE_URI', null), + 'timeout' => env('INTEGRATIONS_TIMEOUT', null), + 'retry' => [ + 'times' => env('INTEGRATIONS_RETRY_TIMES', 0), + 'sleep_ms' => env('INTEGRATIONS_RETRY_SLEEP_MS', 0), + ], + 'default_headers' => [], + 'logging' => [ + 'channel' => env('INTEGRATIONS_LOG_CHANNEL', null), + ], + + /* + |-------------------------------------------------------------------------- + | Circuit Breaker Storage Prefix + |-------------------------------------------------------------------------- + | + | Storage key prefix for circuit breaker state (app-namespaced). + | This ensures multiple applications can share the same Redis/Cache + | without key collisions. + | + | Note: APP_NAME is sanitized to replace ':' with '_' to prevent key parsing issues. + | + */ + 'circuit_breaker_prefix' => env( + 'INTEGRATIONS_CIRCUIT_BREAKER_PREFIX', + 'cb:' . str_replace(':', '_', env('APP_NAME', 'app')) + ), +]; diff --git a/docs/CIRCUIT_BREAKER.md b/docs/CIRCUIT_BREAKER.md new file mode 100644 index 0000000..eb4b4fd --- /dev/null +++ b/docs/CIRCUIT_BREAKER.md @@ -0,0 +1,231 @@ +# Circuit Breaker - Implementation Overview + +> **Brief:** Automatic failure detection and recovery mechanism for HTTP clients. Prevents cascading failures by "opening the circuit" when a service is unhealthy. + +--- + +## What is Circuit Breaker? + +A circuit breaker monitors HTTP requests and automatically stops sending requests to failing services. It has three states: + +- **CLOSED** → Normal operation, requests flow through +- **OPEN** → Service failing, requests are blocked (fail fast) +- **HALF_OPEN** → Testing recovery, limited requests allowed + +``` +CLOSED → (failures exceed threshold) → OPEN +OPEN → (wait interval) → HALF_OPEN +HALF_OPEN → (success) → CLOSED +HALF_OPEN → (failure) → OPEN +``` +--- + +## File Structure + +``` +src/CircuitBreaker/ +├── CircuitBreaker.php # Core circuit breaker logic +├── CircuitBreakerFactory.php # Factory to create instances +├── CircuitBreakerInterceptor.php # HTTP request/response hooks +│ +├── Config/ +│ ├── CircuitBreakerConfig.php # Base configuration (abstract) +│ ├── CountStrategyConfig.php # Config for count-based strategy +│ └── RateStrategyConfig.php # Config for rate-based strategy +│ +├── Contracts/ +│ ├── CircuitBreakerStorage.php # Storage interface +│ └── StrategyInterface.php # Strategy interface +│ +├── Enums/ +│ └── CircuitState.php # CLOSED, OPEN, HALF_OPEN +│ +├── Exceptions/ +│ └── CircuitOpenException.php # Thrown when circuit is open +│ +├── Storage/ +│ ├── CacheStorage.php # Laravel Cache implementation +│ └── RedisStorage.php # Redis implementation +│ +└── Strategy/ + ├── CountStrategy.php # Trip after N failures + └── RateStrategy.php # Trip when failure rate > X% +``` + +--- + +## Key Components + +### 1. Strategies (Tripping Logic) + +Determines **when** to open the circuit. + +#### **CountStrategy** +- Trips after **N consecutive failures** within time window +- Example: Open circuit after 5 failures in 60 seconds +- Use case: Simple failure counting + +#### **RateStrategy** +- Trips when **failure rate > X%** (requires minimum requests) +- Example: Open circuit when 50% of last 10 requests fail +- Use case: Percentage-based thresholds + +### 2. Storage (State Persistence) + +Stores circuit state and metrics. + +#### **RedisStorage** +- Uses Redis for distributed state across servers +- Key format: `{prefix}:{service}:{metric}` +- Supports TTL for auto-cleanup + +#### **CacheStorage** +- Uses Laravel Cache (file, Redis, Memcached, etc.) +- Configurable cache store +- Same interface as RedisStorage + +### 3. Configuration + +#### **CountStrategyConfig** +```php +CountStrategyConfig::make() + ->failureCountThreshold(5) // Trip after 5 failures + ->timeWindow(60) // Within 60 seconds + ->intervalToHalfOpen(30) // Wait 30s before retry + ->successThreshold(3) // 3 successes to close + ->storage('redis'); // Use Redis +``` + +#### **RateStrategyConfig** +```php +RateStrategyConfig::make() + ->failureRateThreshold(50.0) // Trip at 50% failure rate + ->minimumRequests(10) // Need 10 requests minimum + ->timeWindow(60) // Within 60 seconds + ->intervalToHalfOpen(30) // Wait 30s before retry + ->successThreshold(3) // 3 successes to close + ->storage('cache'); // Use Laravel Cache +``` + +--- + +## How It Works + +### Request Flow + +``` +1. HTTP Request + ↓ +2. CircuitBreakerInterceptor::before() + ↓ +3. Check: CircuitBreaker::isAvailable() + ├─ CLOSED/HALF_OPEN → Allow request (proceed to step 4) + └─ OPEN → Throw CircuitOpenException (stop here) + ↓ +4. Execute HTTP request + ↓ +5. Success? + ├─ YES → CircuitBreakerInterceptor::after() → record success + └─ NO → CircuitBreakerInterceptor::onException() → record failure + ↓ +6. Strategy evaluates if circuit should trip + ├─ Threshold exceeded? → Transition to OPEN + └─ Normal → Stay CLOSED +``` + +### State Transitions + +**CLOSED → OPEN** +- Strategy detects threshold exceeded (failures or rate) +- Circuit blocks all requests +- Sets `opened_at` timestamp + +**OPEN → HALF_OPEN** +- After `intervalToHalfOpen` seconds (default: 30s) +- Next request triggers transition +- Allows limited testing + +**HALF_OPEN → CLOSED** +- After `successThreshold` consecutive successes (default: 3) +- Circuit fully recovers +- Resets all metrics + +**HALF_OPEN → OPEN** +- Any failure during testing +- Circuit immediately reopens + +--- + +## Usage Example + +### Basic Setup + +```php +// In your HTTP client class +class MudadClient extends Client +{ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + return RateStrategyConfig::make() + ->failureRateThreshold(40.0) // 40% failure rate + ->minimumRequests(10) // Need 10 requests + ->timeWindow(60) // In 60 seconds + ->intervalToHalfOpen(30) // Wait 30s to retry + ->successThreshold(3) // 3 successes to recover + ->storage('redis') // Use Redis + ->prefix('cb:mudad'); // Custom prefix + } +} +``` + +### Making Requests + +```php +try { + $client = new MudadClient(); + $response = $client->do($request); + // Success - circuit records success +} catch (CircuitOpenException $e) { + // Circuit is open - service is down + // Use fallback logic +} catch (Throwable $e) { + // Other error - circuit records failure +} +``` +--- + +## Configuration Options + +### Common Settings (Both Strategies) + +| Option | Default | Description | +|--------|---------|-------------| +| `timeWindow` | 60 | Time window in seconds to track metrics | +| `intervalToHalfOpen` | 30 | Seconds to wait before attempting recovery | +| `successThreshold` | 3 | Consecutive successes needed to close circuit | +| `storage` | 'cache' | Storage type: 'redis' or 'cache' | +| `prefix` | 'cb:app' | Cache/Redis key prefix | +| `failureStatusCodes` | [500,502,503,504] | HTTP codes considered failures | +| `ignoredStatusCodes` | [] | HTTP codes to ignore | + +### CountStrategy Specific + +| Option | Default | Description | +|--------|---------|-------------| +| `failureCountThreshold` | 5 | Number of failures to trip circuit | + +### RateStrategy Specific + +| Option | Default | Description | +|--------|---------|-------------| +| `failureRateThreshold` | 50.0 | Failure rate percentage (0-100) | +| `minimumRequests` | 10 | Minimum requests before evaluating rate | + +### Storage Options + +| Option | Description | When to Use | +|--------|-------------|-------------| +| `redisConnection()` | Specify Redis connection name | Multiple Redis servers | +| `cacheStore()` | Specify cache store name | Multiple cache backends | + +--- \ No newline at end of file diff --git a/src/CircuitBreaker/CircuitBreaker.php b/src/CircuitBreaker/CircuitBreaker.php new file mode 100644 index 0000000..a70e467 --- /dev/null +++ b/src/CircuitBreaker/CircuitBreaker.php @@ -0,0 +1,224 @@ +getState($service); + + if ($state->isClosed() || $state->isHalfOpen()) { + return true; + } + + if ($this->strategy->shouldAttemptReset($service, $this->storage)) { + $this->transitionTo($service, CircuitState::HALF_OPEN); + return true; + } + + return false; + } + + /** + * @param string $service + * @return CircuitState + * @throws InvalidArgumentException + */ + public function getState(string $service): CircuitState + { + return $this->storage->getState($service); + } + + /** + * @param string $service + * @return void + * @throws InvalidArgumentException + */ + public function success(string $service): void + { + $state = $this->getState($service); + + if ($state->isHalfOpen()) { + $this->storage->incrementHalfOpenSuccess($service); + + if ($this->strategy->shouldClose($service, $this->storage)) { + $this->transitionTo($service, CircuitState::CLOSED); + } + return; + } + + $this->strategy->recordSuccess($service, $this->storage); + } + + /** + * @param string $service + * @return void + * @throws InvalidArgumentException + */ + public function failure(string $service): void + { + $state = $this->getState($service); + + if ($state->isHalfOpen()) { + $this->transitionTo($service, CircuitState::OPEN); + return; + } + + $this->strategy->recordFailure($service, $this->storage); + + if ($this->strategy->shouldTrip($service, $this->storage)) { + $this->transitionTo($service, CircuitState::OPEN); + } + } + + /** + * @param string $service + * @param int $statusCode + * @return void + * @throws InvalidArgumentException + */ + public function recordHttpResult(string $service, int $statusCode): void + { + $this->isFailureStatusCode($statusCode) + ? $this->failure($service) + : $this->success($service); + } + + /** + * @param int $statusCode + * @return bool + */ + public function isFailureStatusCode(int $statusCode): bool + { + if (in_array($statusCode, $this->ignoredStatusCodes, true)) { + return false; + } + + if (! empty($this->failureStatusCodes)) { + return in_array($statusCode, $this->failureStatusCodes, true); + } + + return $statusCode >= 500; + } + + /** + * @param int[] $statusCodes + * @return self + */ + public function setFailureStatusCodes(array $statusCodes): self + { + $this->failureStatusCodes = $statusCodes; + return $this; + } + + /** + * @param int[] $statusCodes + * @return self + */ + public function setIgnoredStatusCodes(array $statusCodes): self + { + $this->ignoredStatusCodes = $statusCodes; + return $this; + } + + /** + * @param string $service + * @param CircuitState $newState + * @return void + * @throws InvalidArgumentException + */ + protected function transitionTo(string $service, CircuitState $newState): void + { + if ($this->getState($service) === $newState) { + return; + } + + // Calculate TTL for OPEN state (auto-cleanup after 1 hour for abandoned circuits) + $ttl = $newState === CircuitState::OPEN + ? 3600 + : null; + + $this->storage->setState($service, $newState, $ttl); + + match ($newState) { + CircuitState::OPEN => $this->onOpen($service), + CircuitState::HALF_OPEN => $this->onHalfOpen($service), + CircuitState::CLOSED => $this->onClosed($service), + }; + } + + /** + * @param string $service + * @return void + */ + protected function onOpen(string $service): void + { + $this->storage->setOpenedAt($service, time()); + $this->storage->resetHalfOpenSuccess($service); + + Logger::warning('CIRCUIT_BREAKER_TRIPPED', [ + 'service' => $service, + 'failures' => $this->storage->getFailureCount($service), + 'successes' => $this->storage->getSuccessCount($service), + 'timestamp' => Carbon::now()->toDateTimeString(), + ]); + } + + /** + * @param string $service + * @return void + */ + protected function onHalfOpen(string $service): void + { + $this->storage->resetHalfOpenSuccess($service); + + Logger::info('CIRCUIT_BREAKER_HALF_OPEN', [ + 'service' => $service, + 'message' => 'Testing recovery', + 'timestamp' => Carbon::now()->toDateTimeString(), + ]); + } + + /** + * @param string $service + * @return void + */ + protected function onClosed(string $service): void + { + $this->storage->reset($service); + + Logger::info('CIRCUIT_BREAKER_CLOSED', [ + 'service' => $service, + 'message' => 'Service recovered', + 'timestamp' => Carbon::now()->toDateTimeString(), + ]); + } +} \ No newline at end of file diff --git a/src/CircuitBreaker/CircuitBreakerFactory.php b/src/CircuitBreaker/CircuitBreakerFactory.php new file mode 100644 index 0000000..3a5c50a --- /dev/null +++ b/src/CircuitBreaker/CircuitBreakerFactory.php @@ -0,0 +1,84 @@ +createStorageFromConfig($config); + $strategy = $this->createStrategyFromConfig($config); + + $circuitBreaker = new CircuitBreaker($storage, $strategy); + + $circuitBreaker->setFailureStatusCodes($config->getFailureStatusCodes()); + $circuitBreaker->setIgnoredStatusCodes($config->getIgnoredStatusCodes()); + + return $circuitBreaker; + } + + /** + * Create storage adapter from configuration. + * + * @param CircuitBreakerConfig $config + * @return CircuitBreakerStorage + */ + protected function createStorageFromConfig(CircuitBreakerConfig $config): CircuitBreakerStorage + { + $storageType = $config->getStorage(); + $prefix = $config->getPrefix(); + + if ($storageType === 'redis') { + return new RedisStorage($prefix, $config->getRedisConnection()); + } + + return new CacheStorage($prefix, $config->getCacheStore()); + } + + /** + * Create strategy from configuration. + * + * @param CircuitBreakerConfig $config + * @return StrategyInterface + * @throws \InvalidArgumentException + */ + protected function createStrategyFromConfig(CircuitBreakerConfig $config): StrategyInterface + { + if ($config instanceof CountStrategyConfig) { + return new CountStrategy( + timeWindow: $config->getTimeWindow(), + failureCountThreshold: $config->getFailureCountThreshold(), + intervalToHalfOpen: $config->getIntervalToHalfOpen(), + successThreshold: $config->getSuccessThreshold() + ); + } + + if ($config instanceof RateStrategyConfig) { + return new RateStrategy( + timeWindow: $config->getTimeWindow(), + failureRateThreshold: $config->getFailureRateThreshold(), + minimumRequests: $config->getMinimumRequests(), + intervalToHalfOpen: $config->getIntervalToHalfOpen(), + successThreshold: $config->getSuccessThreshold() + ); + } + + throw new \InvalidArgumentException('Unknown circuit breaker config type: ' . get_class($config)); + } +} diff --git a/src/CircuitBreaker/CircuitBreakerInterceptor.php b/src/CircuitBreaker/CircuitBreakerInterceptor.php new file mode 100644 index 0000000..8caf304 --- /dev/null +++ b/src/CircuitBreaker/CircuitBreakerInterceptor.php @@ -0,0 +1,84 @@ +circuitBreaker = $circuitBreaker; + } + + + /** + * Check if the circuit allows the request (before middleware). + * + * @throws CircuitOpenException + * @throws InvalidArgumentException + */ + public function before(IRequest $request): void + { + $service = $this->resolveServiceName($request); + + if (!$this->circuitBreaker->isAvailable($service)) { + throw new CircuitOpenException( + $service, + $this->circuitBreaker->getState($service) + ); + } + } + + /** + * Record the result after the request completes (after middleware). + * @throws InvalidArgumentException + */ + public function after(IRequest $request, IResponse $response): void + { + $service = $this->resolveServiceName($request); + $statusCode = $response->statusCode(); + + $this->circuitBreaker->recordHttpResult($service, $statusCode); + } + + /** + * Record a failure when an exception occurs. + * @param IRequest $request + * @throws InvalidArgumentException + */ + public function onException(IRequest $request): void + { + $service = $this->resolveServiceName($request); + $this->circuitBreaker->failure($service); + } + + /** + * Resolve the service name from the request. + * @param IRequest $request + * @return string + */ + protected function resolveServiceName(IRequest $request): string + { + $uri = $request->fullUri(); + + if (empty($uri)) { + $uri = $request->baseUrl() . $request->uri(); + } + + $parsed = parse_url($uri); + + if (isset($parsed['host'])) { + return $parsed['host']; + } + + // Fallback to base URL or URI + return $request->baseUrl() ?: $request->uri(); + } + +} diff --git a/src/CircuitBreaker/Config/CircuitBreakerConfig.php b/src/CircuitBreaker/Config/CircuitBreakerConfig.php new file mode 100644 index 0000000..32d48b9 --- /dev/null +++ b/src/CircuitBreaker/Config/CircuitBreakerConfig.php @@ -0,0 +1,189 @@ +timeWindow = $seconds; + return $this; + } + + /** + * Set the interval in seconds before attempting recovery (OPEN → HALF_OPEN). + * + * @param int $seconds + * @return static + */ + public function intervalToHalfOpen(int $seconds): static + { + if ($seconds < 1) { + throw new \InvalidArgumentException( + "Interval to half-open must be at least 1 second, got: {$seconds}" + ); + } + $this->intervalToHalfOpen = $seconds; + return $this; + } + + /** + * Set the number of consecutive successes needed to close the circuit. + * + * @param int $count + * @return static + */ + public function successThreshold(int $count): static + { + if ($count < 1) { + throw new \InvalidArgumentException( + "Success threshold must be at least 1, got: {$count}" + ); + } + $this->successThreshold = $count; + return $this; + } + + /** + * Set the storage adapter type ('redis' or 'cache'). + * + * @param string $type + * @return static + */ + public function storage(string $type): static + { + $this->storage = $type; + return $this; + } + + /** + * Set the cache key prefix for circuit breaker state. + * + * @param string $prefix + * @return static + */ + public function prefix(string $prefix): static + { + $this->prefix = $prefix; + return $this; + } + + /** + * Set the Redis connection name (only for Redis storage). + * + * @param string|null $connection + * @return static + */ + public function redisConnection(?string $connection): static + { + $this->redisConnection = $connection; + return $this; + } + + /** + * Set the cache store name (only for cache storage). + * + * @param string|null $store + * @return static + */ + public function cacheStore(?string $store): static + { + $this->cacheStore = $store; + return $this; + } + + /** + * Set the HTTP status codes that are considered failures. + * + * @param array $codes + * @return static + */ + public function failureStatusCodes(array $codes): static + { + $this->failureStatusCodes = $codes; + return $this; + } + + /** + * Set the HTTP status codes that should be ignored by the circuit breaker. + * + * @param array $codes + * @return static + */ + public function ignoredStatusCodes(array $codes): static + { + $this->ignoredStatusCodes = $codes; + return $this; + } + + public function getTimeWindow(): int + { + return $this->timeWindow; + } + + public function getIntervalToHalfOpen(): int + { + return $this->intervalToHalfOpen; + } + + public function getSuccessThreshold(): int + { + return $this->successThreshold; + } + + public function getStorage(): string + { + return $this->storage; + } + + public function getPrefix(): string + { + return $this->prefix ?? config('integrations.circuit_breaker_prefix', 'cb:app'); + } + + public function getRedisConnection(): ?string + { + return $this->redisConnection; + } + + public function getCacheStore(): ?string + { + return $this->cacheStore; + } + + public function getFailureStatusCodes(): array + { + return $this->failureStatusCodes; + } + + public function getIgnoredStatusCodes(): array + { + return $this->ignoredStatusCodes; + } + + /** + * Get the strategy type ('rate' or 'count'). + * + * @return string + */ + abstract public function getStrategy(): string; +} diff --git a/src/CircuitBreaker/Config/CountStrategyConfig.php b/src/CircuitBreaker/Config/CountStrategyConfig.php new file mode 100644 index 0000000..9dc293d --- /dev/null +++ b/src/CircuitBreaker/Config/CountStrategyConfig.php @@ -0,0 +1,57 @@ +failureCountThreshold = $count; + return $this; + } + + /** + * @return int + */ + public function getFailureCountThreshold(): int + { + if ($this->failureCountThreshold === null) { + throw new \LogicException( + 'failureCountThreshold not set. Call ->failureCountThreshold() before using this config.' + ); + } + return $this->failureCountThreshold; + } + + /** + * @return string + */ + public function getStrategy(): string + { + return 'count'; + } +} diff --git a/src/CircuitBreaker/Config/RateStrategyConfig.php b/src/CircuitBreaker/Config/RateStrategyConfig.php new file mode 100644 index 0000000..ca92b88 --- /dev/null +++ b/src/CircuitBreaker/Config/RateStrategyConfig.php @@ -0,0 +1,80 @@ + 100) { + throw new \InvalidArgumentException( + "Failure rate threshold must be between 0 and 100, got: {$percentage}" + ); + } + $this->failureRateThreshold = $percentage; + return $this; + } + + /** + * Set the minimum number of requests before evaluating failure rate. + * Circuit won't trip until this many requests have been made. + * + * @param int $count + * @return self + */ + public function minimumRequests(int $count): self + { + if ($count < 1) { + throw new \InvalidArgumentException( + "Minimum requests must be at least 1, got: {$count}" + ); + } + $this->minimumRequests = $count; + return $this; + } + + public function getFailureRateThreshold(): float + { + if ($this->failureRateThreshold === null) { + throw new \LogicException( + 'failureRateThreshold not set. Call ->failureRateThreshold() before using this config.' + ); + } + return $this->failureRateThreshold; + } + + public function getMinimumRequests(): int + { + if ($this->minimumRequests === null) { + throw new \LogicException( + 'minimumRequests not set. Call ->minimumRequests() before using this config.' + ); + } + return $this->minimumRequests; + } + + public function getStrategy(): string + { + return 'rate'; + } +} diff --git a/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php b/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php new file mode 100644 index 0000000..c1b6cf8 --- /dev/null +++ b/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php @@ -0,0 +1,94 @@ +service = $service; + $this->state = $state; + + $message = "Circuit breaker is open for service '{$service}'."; + + parent::__construct($message, 503, $previous); + } + + public function getService(): string + { + return $this->service; + } + + public function getState(): CircuitState + { + return $this->state; + } +} diff --git a/src/CircuitBreaker/Storage/CacheStorage.php b/src/CircuitBreaker/Storage/CacheStorage.php new file mode 100644 index 0000000..58508c8 --- /dev/null +++ b/src/CircuitBreaker/Storage/CacheStorage.php @@ -0,0 +1,190 @@ +cache = Cache::store($store); + $this->prefix = $prefix; + } + + /** + * @param string $service + * @param string $suffix + * @return string + */ + protected function key(string $service, string $suffix): string + { + return "{$this->prefix}:{$service}:{$suffix}"; + } + + /** + * @param string $service + * @return CircuitState + * @throws InvalidArgumentException + */ + public function getState(string $service): CircuitState + { + $state = $this->cache->get($this->key($service, 'state')); + + return CircuitState::tryFrom($state ?: '') ?? CircuitState::CLOSED; + } + + /** + * @param string $service + * @param CircuitState $state + * @param int|null $ttl + * @return void + */ + public function setState(string $service, CircuitState $state, ?int $ttl = null): void + { + $key = $this->key($service, 'state'); + + if ($ttl !== null) { + $this->cache->put($key, $state->value, $ttl); + } else { + $this->cache->forever($key, $state->value); + } + } + + /** + * @param string $service + * @param int $timeWindow + * @return int + */ + public function incrementFailure(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'failures'), $timeWindow); + } + + /** + * @param string $service + * @param int $timeWindow + * @return int + */ + public function incrementSuccess(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'successes'), $timeWindow); + } + + /** + * @param string $key + * @param int $ttl + * @return int + */ + protected function incrementWithExpiry(string $key, int $ttl): int + { + $this->cache->add($key, 0, $ttl); + + return (int)$this->cache->increment($key); + } + + /** + * @param string $service + * @return int + * @throws InvalidArgumentException + */ + public function getFailureCount(string $service): int + { + return (int)$this->cache->get($this->key($service, 'failures'), 0); + } + + /** + * @param string $service + * @return int + * @throws InvalidArgumentException + */ + public function getSuccessCount(string $service): int + { + return (int)$this->cache->get($this->key($service, 'successes'), 0); + } + + /** + * @param string $service + * @return int + * @throws InvalidArgumentException + */ + public function getRequestCount(string $service): int + { + return $this->getFailureCount($service) + $this->getSuccessCount($service); + } + + /** + * @param string $service + * @return void + */ + public function reset(string $service): void + { + $suffixes = ['state', 'failures', 'successes', 'opened_at', 'half_open_successes']; + + foreach ($suffixes as $suffix) { + $this->cache->forget($this->key($service, $suffix)); + } + } + + /** + * @param string $service + * @return int|null + * @throws InvalidArgumentException + */ + public function getOpenedAt(string $service): ?int + { + $value = $this->cache->get($this->key($service, 'opened_at')); + + return $value ? (int)$value : null; + } + + /** + * Auto-expire after 1 hour to prevent abandoned circuits + * @param string $service + * @param int $timestamp + * @return void + */ + public function setOpenedAt(string $service, int $timestamp): void + { + $this->cache->forever($this->key($service, 'opened_at'), $timestamp); + } + + /** + * @param string $service + * @return int + * @throws InvalidArgumentException + */ + public function getHalfOpenSuccessCount(string $service): int + { + return (int)$this->cache->get($this->key($service, 'half_open_successes'), 0); + } + + /** + * @param string $service + * @return int + */ + public function incrementHalfOpenSuccess(string $service): int + { + return (int) $this->cache->increment($this->key($service, 'half_open_successes')); + } + + /** + * @param string $service + * @return void + */ + public function resetHalfOpenSuccess(string $service): void + { + $this->cache->forget($this->key($service, 'half_open_successes')); + } +} diff --git a/src/CircuitBreaker/Storage/RedisStorage.php b/src/CircuitBreaker/Storage/RedisStorage.php new file mode 100644 index 0000000..52f3f84 --- /dev/null +++ b/src/CircuitBreaker/Storage/RedisStorage.php @@ -0,0 +1,197 @@ +redis = Redis::connection($connection); + $this->prefix = $prefix; + } + + /** + * @param string $service + * @param string $suffix + * @return string + */ + protected function key(string $service, string $suffix): string + { + return "{$this->prefix}:{$service}:{$suffix}"; + } + + /** + * @param string $service + * @return CircuitState + */ + public function getState(string $service): CircuitState + { + $state = $this->redis->get($this->key($service, 'state')); + + return CircuitState::tryFrom($state ?: '') ?? CircuitState::CLOSED; + } + + /** + * @param string $service + * @param CircuitState $state + * @param int|null $ttl + * @return void + */ + public function setState(string $service, CircuitState $state, ?int $ttl = null): void + { + $key = $this->key($service, 'state'); + + if ($ttl !== null) { + $this->redis->setex($key, $ttl, $state->value); + } else { + $this->redis->set($key, $state->value); + } + } + + /** + * Increment failure count with expiry + * @param string $service + * @param int $timeWindow + * @return int + */ + public function incrementFailure(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'failures'), $timeWindow); + } + + /** + * Increment success count with expiry + * @param string $service + * @param int $timeWindow + * @return int + */ + public function incrementSuccess(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'successes'), $timeWindow); + } + + /** + * Lua script for atomic increment with conditional TTL + * This prevents race conditions in high concurrency scenarios + * TTL is only set on first increment (when counter = 1) + * Subsequent increments preserve the original TTL for true sliding window behavior + * @param string $key + * @param int $ttl + * @return int + */ + protected function incrementWithExpiry(string $key, int $ttl): int + { + $script = <<<'LUA' + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current +LUA; + + return (int) $this->redis->eval($script, 1, $key, $ttl); + } + + /** + * @param string $service + * @return int + */ + public function getFailureCount(string $service): int + { + return (int) ($this->redis->get($this->key($service, 'failures')) ?: 0); + } + + /** + * @param string $service + * @return int + */ + public function getSuccessCount(string $service): int + { + return (int) ($this->redis->get($this->key($service, 'successes')) ?: 0); + } + + /** + * @param string $service + * @return int + */ + public function getRequestCount(string $service): int + { + return $this->getFailureCount($service) + $this->getSuccessCount($service); + } + + /** + * @param string $service + * @return void + */ + public function reset(string $service): void + { + $this->redis->del([ + $this->key($service, 'state'), + $this->key($service, 'failures'), + $this->key($service, 'successes'), + $this->key($service, 'opened_at'), + $this->key($service, 'half_open_successes'), + ]); + } + + /** + * @param string $service + * @return int|null + */ + public function getOpenedAt(string $service): ?int + { + $value = $this->redis->get($this->key($service, 'opened_at')); + + return $value ? (int) $value : null; + } + + /** + * Auto-expire after 1 hour to prevent abandoned circuits + * @param string $service + * @param int $timestamp + * @return void + */ + public function setOpenedAt(string $service, int $timestamp): void + { + $this->redis->set($this->key($service, 'opened_at'), $timestamp); + } + + /** + * @param string $service + * @return int + */ + public function getHalfOpenSuccessCount(string $service): int + { + return (int) ($this->redis->get($this->key($service, 'half_open_successes')) ?: 0); + } + + /** + * @param string $service + * @return int + */ + public function incrementHalfOpenSuccess(string $service): int + { + return (int) $this->redis->incr($this->key($service, 'half_open_successes')); + } + + /** + * @param string $service + * @return void + */ + public function resetHalfOpenSuccess(string $service): void + { + $this->redis->del([$this->key($service, 'half_open_successes')]); + } +} diff --git a/src/CircuitBreaker/Strategy/CountStrategy.php b/src/CircuitBreaker/Strategy/CountStrategy.php new file mode 100644 index 0000000..25246f7 --- /dev/null +++ b/src/CircuitBreaker/Strategy/CountStrategy.php @@ -0,0 +1,122 @@ +getFailureCount($service); + + return $failures >= $this->failureCountThreshold; + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ + public function shouldClose(string $service, CircuitBreakerStorage $storage): bool + { + $halfOpenSuccesses = $storage->getHalfOpenSuccessCount($service); + + return $halfOpenSuccesses >= $this->successThreshold; + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ + public function shouldAttemptReset(string $service, CircuitBreakerStorage $storage): bool + { + $openedAt = $storage->getOpenedAt($service); + + if ($openedAt === null) { + return true; + } + + return (time() - $openedAt) >= $this->intervalToHalfOpen; + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return void + */ + public function recordSuccess(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementSuccess($service, $this->timeWindow); + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return void + */ + public function recordFailure(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementFailure($service, $this->timeWindow); + } + + /** + * @return int + */ + public function getTimeWindow(): int + { + return $this->timeWindow; + } + + /** + * @return int + */ + public function getIntervalToHalfOpen(): int + { + return $this->intervalToHalfOpen; + } + + /** + * @return int + */ + public function getMinimumRequests(): int + { + return 0; // Count strategy doesn't require minimum requests + } + + /** + * @return int + */ + public function getSuccessThreshold(): int + { + return $this->successThreshold; + } + + /** + * @return int + */ + public function getFailureCountThreshold(): int + { + return $this->failureCountThreshold; + } +} \ No newline at end of file diff --git a/src/CircuitBreaker/Strategy/RateStrategy.php b/src/CircuitBreaker/Strategy/RateStrategy.php new file mode 100644 index 0000000..f946282 --- /dev/null +++ b/src/CircuitBreaker/Strategy/RateStrategy.php @@ -0,0 +1,124 @@ +getRequestCount($service); + + if ($totalRequests < $this->minimumRequests) { + return false; + } + + $failureRate = ($storage->getFailureCount($service) / $totalRequests) * 100; + + return $failureRate >= $this->failureRateThreshold; + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ + public function shouldClose(string $service, CircuitBreakerStorage $storage): bool + { + return $storage->getHalfOpenSuccessCount($service) >= $this->successThreshold; + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ + public function shouldAttemptReset(string $service, CircuitBreakerStorage $storage): bool + { + $openedAt = $storage->getOpenedAt($service); + + return $openedAt === null || (time() - $openedAt) >= $this->intervalToHalfOpen; + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return void + */ + public function recordSuccess(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementSuccess($service, $this->timeWindow); + } + + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return void + */ + public function recordFailure(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementFailure($service, $this->timeWindow); + } + + /** + * @return int + */ + public function getTimeWindow(): int + { + return $this->timeWindow; + } + + /** + * @return int + */ + public function getIntervalToHalfOpen(): int + { + return $this->intervalToHalfOpen; + } + + /** + * @return int + */ + public function getMinimumRequests(): int + { + return $this->minimumRequests; + } + + /** + * @return int + */ + public function getSuccessThreshold(): int + { + return $this->successThreshold; + } + + /** + * @return float + */ + public function getFailureRateThreshold(): float + { + return $this->failureRateThreshold; + } +} \ No newline at end of file diff --git a/src/Client.php b/src/Client.php index a12e380..4c19a5d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,187 +1,241 @@ -baseUri = $baseUri; - $this->headers = $headers; - $this->options = $options; - - $this->transport = new LaravelHttpTransport($this->headers, $this->timeout, $this->retryTimes, $this->retrySleepMs); - $this->builder = new RequestContextBuilder($this->baseUri, $this->headers, $this->options); - $this->logger = new HttpLogger(); - $this->responseFactory = new ResponseFactory(); - $this->mapper = new DefaultResponseMapper(); - } - - public function withHeaders(array $headers): ClientInterface - { - $normalized = []; - foreach ($headers as $k => $v) { - if ($k instanceof HK) { $normalized[$k->key()] = $v; } - else { $normalized[is_string($k) ? $k : (string) $k] = $v; } - } - $this->headers = HeaderBag::merge($this->headers, $normalized); - $this->transport->withHeaders($this->headers); - $this->builder->withHeaders($this->headers); - return $this; - } - - public function setHeader(string|HK $key, mixed $value): ClientInterface - { - if ($key instanceof HK) { $key = $key->key(); } - $this->headers = HeaderBag::merge($this->headers, [$key => $value]); - $this->transport->withHeaders($this->headers); - $this->builder->withHeaders($this->headers); - return $this; - } - - public function withBearer(string $token): ClientInterface - { - return $this->setHeader(HK::AUTHORIZATION, 'Bearer ' . $token); - } - - public function withBasicAuth(string $username, string $password): ClientInterface - { - $this->options[RequestOptions::AUTH] = [$username, $password]; - $this->builder->withOptions($this->options); - return $this; - } - - public function withBaseUri(?string $baseUri): ClientInterface - { - $this->baseUri = $baseUri; - $this->builder->withBaseUri($this->baseUri); - return $this; - } - - public function retry(int $times, int $sleepMs = 0): ClientInterface - { - $this->retryTimes = $times; - $this->retrySleepMs = $sleepMs; - $this->transport->retry($times, $sleepMs); - return $this; - } - - public function timeout(int $seconds): ClientInterface - { - $this->timeout = $seconds; - $this->transport->timeout($seconds); - return $this; - } - - public function withOption(string $key, mixed $value): ClientInterface - { - $this->options[$key] = $value; - $this->builder->withOptions($this->options); - return $this; - } - - public function withOptions(array $options): ClientInterface - { - $this->options = array_replace($this->options, $options); - $this->builder->withOptions($this->options); - return $this; - } - - public function do(RequestInterface $request): ResponseInterface - { - $request->runBeforeMiddlewares($this); - - $ctx = $this->builder->build($request); - $url = $ctx['url']; - $options = $ctx['options']; - $method = $request->method()->value; - $mergedHeaders = HeaderBag::merge($this->headers, $request->headers()); - - $this->logger->logRequest([ - 'method' => $method, - 'url' => $url, - 'headers' => $this->redactHeaders($mergedHeaders), - 'query' => $request->query(), - 'body' => $this->redactBody($request->body()), - ]); - - $res = $this->transport - ->withHeaders($mergedHeaders) - ->send($method, $url, $options); - - $response = $this->createResponse($request, $res); - - $request->runAfterMiddlewares($this); - - $this->logger->logResponse($response); - - return $response; - } - - public function process(RequestInterface $request): Contracts\IDto - { - $response = $this->do($request); - return $this->responseToDto($response); - } - - protected function createResponse(RequestInterface $request, HttpResponse $res): ResponseInterface - { - return $this->responseFactory->create($request, $res); - } - - protected function responseToDto(ResponseInterface $response) - { - return $this->mapper->map($response); - } - - protected function redactHeaders(array $headers): array - { - $out = []; - foreach ($headers as $k => $v) { - $key = is_string($k) ? strtolower($k) : (string) $k; - $out[$k] = in_array($key, ['authorization','x-api-key']) ? $this->maskToken((string)$v) : $v; - } - return $out; - } - - protected function redactBody(array $body): array - { - $sensitive = ['password','token','access_token','secret','client_secret','authorization']; - foreach ($sensitive as $k) if (array_key_exists($k, $body)) $body[$k] = '******'; - return $body; - } - - protected function maskToken(string $value): string - { - $len = strlen($value); - if ($len <= 8) return '******'; - return substr($value, 0, 4) . str_repeat('*', max(0, $len - 8)) . substr($value, -4); - } -} +baseUri = $baseUri; + $this->headers = $headers; + $this->options = $options; + + $this->transport = new LaravelHttpTransport($this->headers, $this->timeout, $this->retryTimes, $this->retrySleepMs); + $this->builder = new RequestContextBuilder($this->baseUri, $this->headers, $this->options); + $this->logger = new HttpLogger(); + $this->responseFactory = new ResponseFactory(); + $this->mapper = new DefaultResponseMapper(); + $this->configureCircuitBreakerFromConfig(); + } + + public function withHeaders(array $headers): ClientInterface + { + $normalized = []; + foreach ($headers as $k => $v) { + if ($k instanceof HK) { $normalized[$k->key()] = $v; } + else { $normalized[is_string($k) ? $k : (string) $k] = $v; } + } + $this->headers = HeaderBag::merge($this->headers, $normalized); + $this->transport->withHeaders($this->headers); + $this->builder->withHeaders($this->headers); + return $this; + } + + public function setHeader(string|HK $key, mixed $value): ClientInterface + { + if ($key instanceof HK) { $key = $key->key(); } + $this->headers = HeaderBag::merge($this->headers, [$key => $value]); + $this->transport->withHeaders($this->headers); + $this->builder->withHeaders($this->headers); + return $this; + } + + public function withBearer(string $token): ClientInterface + { + return $this->setHeader(HK::AUTHORIZATION, 'Bearer ' . $token); + } + + public function withBasicAuth(string $username, string $password): ClientInterface + { + $this->options[RequestOptions::AUTH] = [$username, $password]; + $this->builder->withOptions($this->options); + return $this; + } + + public function withBaseUri(?string $baseUri): ClientInterface + { + $this->baseUri = $baseUri; + $this->builder->withBaseUri($this->baseUri); + return $this; + } + + public function retry(int $times, int $sleepMs = 0): ClientInterface + { + $this->retryTimes = $times; + $this->retrySleepMs = $sleepMs; + $this->transport->retry($times, $sleepMs); + return $this; + } + + public function timeout(int $seconds): ClientInterface + { + $this->timeout = $seconds; + $this->transport->timeout($seconds); + return $this; + } + + public function withOption(string $key, mixed $value): ClientInterface + { + $this->options[$key] = $value; + $this->builder->withOptions($this->options); + return $this; + } + + public function withOptions(array $options): ClientInterface + { + $this->options = array_replace($this->options, $options); + $this->builder->withOptions($this->options); + return $this; + } + + /** + * @throws Throwable + * @throws CircuitOpenException + */ + public function do(RequestInterface $request): ResponseInterface + { + $this->circuitBreakerInterceptor?->before($request); + + + $request->runBeforeMiddlewares($this); + + $ctx = $this->builder->build($request); + $url = $ctx['url']; + $options = $ctx['options']; + $method = $request->method()->value; + $mergedHeaders = HeaderBag::merge($this->headers, $request->headers()); + + $this->logger->logRequest([ + 'method' => $method, + 'url' => $url, + 'headers' => $this->redactHeaders($mergedHeaders), + 'query' => $request->query(), + 'body' => $this->redactBody($request->body()), + ]); + + try { + $res = $this->transport + ->withHeaders($mergedHeaders) + ->send($method, $url, $options); + + $response = $this->createResponse($request, $res); + + $this->circuitBreakerInterceptor?->after($request, $response); + + $request->runAfterMiddlewares($this); + + $this->logger->logResponse($response); + + return $response; + } catch (Throwable $e) { + $this->circuitBreakerInterceptor?->onException($request); + throw $e; + } + } + + public function process(RequestInterface $request): Contracts\IDto + { + $response = $this->do($request); + return $this->responseToDto($response); + } + + protected function createResponse(RequestInterface $request, HttpResponse $res): ResponseInterface + { + return $this->responseFactory->create($request, $res); + } + + protected function responseToDto(ResponseInterface $response) + { + return $this->mapper->map($response); + } + + protected function redactHeaders(array $headers): array + { + $out = []; + foreach ($headers as $k => $v) { + $key = is_string($k) ? strtolower($k) : (string) $k; + $out[$k] = in_array($key, ['authorization','x-api-key']) ? $this->maskToken((string)$v) : $v; + } + return $out; + } + + protected function redactBody(array $body): array + { + $sensitive = ['password','token','access_token','secret','client_secret','authorization']; + foreach ($sensitive as $k) if (array_key_exists($k, $body)) $body[$k] = '******'; + return $body; + } + + protected function maskToken(string $value): string + { + $len = strlen($value); + if ($len <= 8) return '******'; + return substr($value, 0, 4) . str_repeat('*', max(0, $len - 8)) . substr($value, -4); + } + + /** + * Override this method in subclasses to provide custom configuration. + * + * @return CircuitBreakerConfig|null + */ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + return null; + } + + /** + * Auto-configure circuit breaker from config if provided. + * Only custom config supported - no global fallback + * @return void + */ + protected function configureCircuitBreakerFromConfig(): void + { + $config = $this->circuitBreakerConfig(); + if ($config !== null) { + $factory = app(CircuitBreakerFactory::class); + $circuitBreaker = $factory->createFromConfig($config); + $this->withCircuitBreaker($circuitBreaker); + } + } + + public function withCircuitBreaker(CircuitBreaker $circuitBreaker): ClientInterface + { + $this->circuitBreakerInterceptor = new CircuitBreakerInterceptor($circuitBreaker); + return $this; + } + +} diff --git a/src/Contracts/IClient.php b/src/Contracts/IClient.php index d9a08e9..358495c 100755 --- a/src/Contracts/IClient.php +++ b/src/Contracts/IClient.php @@ -1,11 +1,14 @@ -app->bind(ResponseMapperInterface::class, DefaultResponseMapper::class); + + // Register circuit breaker factory for per-service configuration + $this->app->singleton(CircuitBreakerFactory::class, function () { + return new CircuitBreakerFactory(); + }); } public function boot(): void