From c3fd8415a9ef0e82e4311bf0dce13c3b2975f009 Mon Sep 17 00:00:00 2001 From: Bilal-Elhalawaty Date: Sun, 28 Dec 2025 10:44:18 +0200 Subject: [PATCH 1/3] feat: add circuit breaker with rate and count strategies Prevents cascading failures by automatically blocking requests to failing services. Supports per-service configuration with customizable thresholds and controlled recovery testing via HALF_OPEN state. - Rate strategy: percentage-based failure threshold - Count strategy: absolute failure count threshold - Redis/Cache storage with sliding time windows - Comprehensive documentation and examples --- config/integrations.php | 56 +- docs/CIRCUIT_BREAKER.md | 628 ++++++++++++++++++ src/CircuitBreaker/CircuitBreaker.php | 180 +++++ src/CircuitBreaker/CircuitBreakerFactory.php | 88 +++ .../CircuitBreakerInterceptor.php | 77 +++ .../Config/CircuitBreakerConfig.php | 184 +++++ .../Config/CountStrategyConfig.php | 47 ++ .../Config/RateStrategyConfig.php | 66 ++ .../Contracts/CircuitBreakerStorage.php | 73 ++ .../Contracts/StrategyInterface.php | 51 ++ src/CircuitBreaker/Enums/CircuitState.php | 20 + .../Exceptions/CircuitOpenException.php | 35 + src/CircuitBreaker/Storage/CacheStorage.php | 136 ++++ src/CircuitBreaker/Storage/RedisStorage.php | 114 ++++ src/CircuitBreaker/Strategy/CountStrategy.php | 82 +++ src/CircuitBreaker/Strategy/RateStrategy.php | 83 +++ src/Client.php | 471 +++++++------ src/Contracts/IClient.php | 25 +- src/IntegrationsServiceProvider.php | 131 +++- 19 files changed, 2290 insertions(+), 257 deletions(-) create mode 100644 docs/CIRCUIT_BREAKER.md create mode 100644 src/CircuitBreaker/CircuitBreaker.php create mode 100644 src/CircuitBreaker/CircuitBreakerFactory.php create mode 100644 src/CircuitBreaker/CircuitBreakerInterceptor.php create mode 100644 src/CircuitBreaker/Config/CircuitBreakerConfig.php create mode 100644 src/CircuitBreaker/Config/CountStrategyConfig.php create mode 100644 src/CircuitBreaker/Config/RateStrategyConfig.php create mode 100644 src/CircuitBreaker/Contracts/CircuitBreakerStorage.php create mode 100644 src/CircuitBreaker/Contracts/StrategyInterface.php create mode 100644 src/CircuitBreaker/Enums/CircuitState.php create mode 100644 src/CircuitBreaker/Exceptions/CircuitOpenException.php create mode 100644 src/CircuitBreaker/Storage/CacheStorage.php create mode 100644 src/CircuitBreaker/Storage/RedisStorage.php create mode 100644 src/CircuitBreaker/Strategy/CountStrategy.php create mode 100644 src/CircuitBreaker/Strategy/RateStrategy.php diff --git a/config/integrations.php b/config/integrations.php index 8cb1fa5..da93fd1 100644 --- a/config/integrations.php +++ b/config/integrations.php @@ -1,13 +1,43 @@ - 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 Configuration (Default Settings) + |-------------------------------------------------------------------------- + | + | Default circuit breaker settings that services can opt-in to use. + | + | Per-service control: + | 1. Custom config: Override circuitBreakerConfig() method for custom settings + | 2. Default settings: Set $circuitBreakerEnabled = true to use these defaults + | 3. Disabled: Default behavior (no override needed) + | + | See: docs/CIRCUIT_BREAKER_PER_SERVICE.md + | + */ + 'circuit_breaker' => [ + 'storage' => env('INTEGRATIONS_CIRCUIT_BREAKER_STORAGE', 'cache'), + 'cache_store' => env('INTEGRATIONS_CIRCUIT_BREAKER_CACHE_STORE', null), + 'prefix' => env('INTEGRATIONS_CIRCUIT_BREAKER_PREFIX', 'circuit_breaker'), + 'strategy' => env('INTEGRATIONS_CIRCUIT_BREAKER_STRATEGY', 'rate'), + 'time_window' => env('INTEGRATIONS_CIRCUIT_BREAKER_TIME_WINDOW', 60), + 'failure_rate_threshold' => env('INTEGRATIONS_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD', 50), + 'failure_count_threshold' => env('INTEGRATIONS_CIRCUIT_BREAKER_FAILURE_COUNT_THRESHOLD', 5), + 'minimum_requests' => env('INTEGRATIONS_CIRCUIT_BREAKER_MINIMUM_REQUESTS', 10), + 'interval_to_half_open' => env('INTEGRATIONS_CIRCUIT_BREAKER_INTERVAL_TO_HALF_OPEN', 30), + 'success_threshold' => env('INTEGRATIONS_CIRCUIT_BREAKER_SUCCESS_THRESHOLD', 3), + 'failure_status_codes' => [500, 502, 503, 504], + 'ignored_status_codes' => [], + ], +]; diff --git a/docs/CIRCUIT_BREAKER.md b/docs/CIRCUIT_BREAKER.md new file mode 100644 index 0000000..9e4dff8 --- /dev/null +++ b/docs/CIRCUIT_BREAKER.md @@ -0,0 +1,628 @@ +# Circuit Breaker + +## What Is It? + +Protects your application from cascading failures when external services become unreliable. Automatically stops requests to failing services and gives them time to recover. + +**What "Trip" Means:** +- **Trip** = Circuit transitions from CLOSED (normal) to OPEN (blocking requests) +- When circuit "trips", it opens and blocks all requests to protect your system +- After a cooldown period, it tests if the service recovered + +--- + +## Three Circuit States + +``` +CLOSED 🟢 (Normal - all requests allowed) + ↓ + Too many failures detected + ↓ +OPEN 🔴 (Service down - block ALL requests) + ↓ + Wait for cooldown period (e.g., 30s) + ↓ +HALF_OPEN ⚠️ (Testing - allow FEW requests to test recovery) + ↓ + If 3 successes → CLOSED 🟢 (recovered!) + If 1 failure → OPEN 🔴 (still broken, wait longer) +``` + +### Why HALF_OPEN is Needed + +Without HALF_OPEN, you'd have two bad options: +- ❌ **Stay OPEN forever** → Never recover, block healthy services +- ❌ **Auto-CLOSE immediately** → Flood failing service with thousands of requests + +**HALF_OPEN = Controlled Testing** +- After cooldown (e.g., 30s), test with just a FEW requests (e.g., 3) +- If those succeed → Service is healthy → Fully reopen +- If any fail → Service still broken → Wait longer + +--- + +## How to Use + +### Option 1: Custom Configuration Per Service + +```php +use Idaratech\Integrations\Client; +use Idaratech\Integrations\CircuitBreaker\Config\RateStrategyConfig; + +class PaymentClient extends Client +{ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + return RateStrategyConfig::make() + ->failureRateThreshold(40.0) // Trip at 40% failure rate + ->minimumRequests(10) // Need 10 requests minimum + ->timeWindow(60) // Track failures in last 60s + ->intervalToHalfOpen(30) // Wait 30s before testing recovery + ->successThreshold(3) // Need 3 successes to close + ->storage('redis'); + } +} +``` + +### Option 2: Count Strategy (Absolute Failures) + +```php +use Idaratech\Integrations\CircuitBreaker\Config\CountStrategyConfig; + +class EmailClient extends Client +{ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + return CountStrategyConfig::make() + ->failureCountThreshold(5) // Trip after 5 failures + ->intervalToHalfOpen(120) // Wait 2 minutes + ->storage('redis'); + } +} +``` + +--- + +## Strategies Explained + +### Rate Strategy (Percentage-Based) + +Trips when **failure percentage** exceeds threshold. + +**Formula:** +``` +failureRate = (failures / totalRequests) × 100 + +if totalRequests >= minimumRequests AND failureRate >= threshold: + TRIP → OPEN +``` + +**Example:** +```php +RateStrategyConfig::make() + ->failureRateThreshold(50.0) // 50% threshold + ->minimumRequests(10); // Need at least 10 requests +``` + +**Scenarios:** +``` +Scenario 1: Not enough data +Total: 8 requests, 5 failures (62.5% failure rate) +Result: DON'T TRIP (8 < 10 minimum requests) + +Scenario 2: Below threshold +Total: 20 requests, 8 failures (40% failure rate) +Result: DON'T TRIP (40% < 50%) + +Scenario 3: Exceeds threshold - TRIP! +Total: 20 requests, 11 failures (55% failure rate) +Result: TRIP! (55% >= 50% AND 20 >= 10) +``` + +**Best for:** High-traffic services, external APIs + +--- + +### Count Strategy (Absolute Count) + +Trips when **absolute failure count** exceeds threshold. + +**Formula:** +``` +if failures >= threshold: + TRIP → OPEN +``` + +**Example:** +```php +CountStrategyConfig::make() + ->failureCountThreshold(5); // Trip after 5 failures +``` + +**Scenarios:** +``` +Scenario 1: Below threshold +Failures: 3, Successes: 100 +Result: DON'T TRIP (3 < 5) + +Scenario 2: Reaches threshold - TRIP! +Failures: 5, Successes: 2 +Result: TRIP! (5 >= 5) +``` + +**Best for:** Critical services (payments), low-traffic services, strict failure policies + +--- + +### Strategy Comparison + +| Aspect | Rate Strategy | Count Strategy | +|--------|--------------|----------------| +| **Trips based on** | Percentage (failures/total) | Absolute number | +| **Requires minimum requests** | Yes | No | +| **Formula** | `(failures/total)×100 >= 50%` | `failures >= 5` | +| **Best for** | High traffic, variable load | Critical systems, low traffic | +| **Example** | 11 failures out of 20 = 55% | 5 failures (regardless of total) | + +--- + +## Configuration Parameters Explained + +### 1. `timeWindow` - Memory Span (ALWAYS Active) + +**What it does:** Defines how long to "remember" failures and successes + +**Always running:** Tracks in ALL states (CLOSED, OPEN, HALF_OPEN) + +**Example:** +```php +->timeWindow(60) // Track failures in last 60 seconds +``` + +**How it works:** +``` +Current time: 10:01:00 + +Failures in storage: +09:59:50 - Failure ❌ EXPIRED (outside 60s window) +10:00:05 - Failure ✓ COUNTED (within 60s) +10:00:30 - Failure ✓ COUNTED (within 60s) +10:00:50 - Failure ✓ COUNTED (within 60s) + +Only count: 3 failures (last 60 seconds) +Old failures automatically expire! +``` + +**Purpose:** +- Prevents old failures from affecting current decisions +- Keeps circuit breaker responsive to current service health +- Creates a "sliding window" that moves with time + +--- + +### 2. `intervalToHalfOpen` - Recovery Cooldown (Only When OPEN) + +**What it does:** How long to wait after circuit opens before testing recovery + +**Only active:** When circuit is OPEN + +**Example:** +```php +->intervalToHalfOpen(30) // Wait 30 seconds before testing +``` + +**How it works:** +``` +10:00:00 - Circuit OPENS + Start timer from HERE + +10:00:10 - Request arrives + 10s < 30s → BLOCKED ⛔ + +10:00:30 - Request arrives + 30s >= 30s → Transition to HALF_OPEN ✓ + Request ALLOWED (testing recovery) +``` + +**Purpose:** +- Gives failing service time to recover +- Prevents immediate retry attempts + +--- + +### 3. `successThreshold` - Recovery Test (Only in HALF_OPEN) + +**What it does:** How many CONSECUTIVE successes needed to close circuit + +**Only active:** When circuit is HALF_OPEN + +**NOT tracked in timeWindow!** Separate counter, resets on any failure. + +**Example:** +```php +->successThreshold(3) // Need 3 consecutive successes +``` + +**How it works:** +``` +State: HALF_OPEN + +Request 1: 200 ✓ → successCount = 1 +Request 2: 200 ✓ → successCount = 2 +Request 3: 200 ✓ → successCount = 3 +→ Circuit CLOSES! 🟢 + +Alternative (failure during test): +Request 1: 200 ✓ → successCount = 1 +Request 2: 500 ✗ → FAILURE! +→ Circuit goes back to OPEN 🔴 +→ Reset counter to 0 +→ Wait another 30s before retrying +``` + +**Purpose:** +- Ensures service is truly recovered (not just one lucky success) +- Prevents flapping (OPEN → CLOSED → OPEN) + +--- + +### Visual Summary of Parameters + +``` +timeWindow(60) - ALWAYS SLIDING +├─────────────────────────────────────────────────────┤ +09:59:50 10:00:00 10:00:30 10:01:00 + ❌ ✓ ✓ NOW +expired counted counted +└──────────── Only count last 60s ────────────┘ + + +intervalToHalfOpen(30) - ONLY WHEN OPEN +Circuit OPENS ├─────────────┤ Test recovery + 10:00:00 10:00:30 + ↓ + HALF_OPEN + + +successThreshold(3) - ONLY IN HALF_OPEN +HALF_OPEN: ✓ ✓ ✓ → CLOSED +HALF_OPEN: ✓ ✗ → Back to OPEN + └─ Reset counter +``` + +--- + +## Complete Lifecycle Example + +**Configuration:** +```php +RateStrategyConfig::make() + ->failureRateThreshold(50.0) // 50% failures + ->minimumRequests(10) + ->timeWindow(60) // Track last 60s + ->intervalToHalfOpen(30) // Wait 30s before testing + ->successThreshold(3); // Need 3 successes +``` + +**Timeline:** + +``` +State: CLOSED 🟢 +════════════════════════════════════════════════════════ +10:00:00-10:00:40 - 9 requests (4 success, 5 failures) + 9 < 10 minimum → Don't trip yet + +10:00:45 - Request 10: 500 ✗ + Total: 10 requests (4 success, 6 failures) + Failure rate: (6/10) × 100 = 60% + 60% >= 50% threshold → TRIP! 🔴 + +State: OPEN 🔴 (openedAt = 10:00:45) +════════════════════════════════════════════════════════ +10:00:50 - Request arrives + Time since opened: 5s < 30s + → BLOCKED ⛔ (throw CircuitOpenException) + +10:01:00 - Request arrives + 15s < 30s → BLOCKED ⛔ + +10:01:15 - Request arrives (30s passed!) + 30s >= 30s → Transition to HALF_OPEN ⚠️ + → Request ALLOWED (testing) + +State: HALF_OPEN ⚠️ (testing recovery) +════════════════════════════════════════════════════════ +10:01:15 - Request 1: 200 ✓ (successCount = 1/3) +10:01:20 - Request 2: 200 ✓ (successCount = 2/3) +10:01:25 - Request 3: 200 ✓ (successCount = 3/3) + 3 >= 3 → Circuit CLOSES! 🟢 + +State: CLOSED 🟢 (service recovered!) +════════════════════════════════════════════════════════ +10:01:30 - Back to normal operation + Start tracking new failures in timeWindow +``` + +--- + +## Storage + +Circuit state is stored per service using Redis or Cache. + +**Key Structure:** +``` +circuit_breaker:{service}:state → "closed", "open", "half_open" +circuit_breaker:{service}:failures → 3 +circuit_breaker:{service}:successes → 15 +circuit_breaker:{service}:opened_at → 1703520000 +circuit_breaker:{service}:half_open_successes → 2 +``` + +**Multiple Services:** +``` +circuit_breaker:api.stripe.com:state → "open" (down) +circuit_breaker:api.twilio.com:state → "closed" (working) +circuit_breaker:payment.internal:state → "half_open" (testing) +``` + +--- + +## FAQ - Common Questions + +### Q1: What does "trip" mean? + +**Trip** means the circuit breaker transitions from CLOSED to OPEN state. +- Circuit "trips" when too many failures detected +- Once tripped, circuit is OPEN and blocks all requests +- Protects your system from cascading failures + +--- + +### Q2: Why do we need HALF_OPEN state? Can't we just go CLOSED → OPEN → CLOSED? + +**Without HALF_OPEN you'd have problems:** + +❌ **Option A:** Circuit stays OPEN forever +- Service recovers but circuit never reopens +- You're blocking requests to a healthy service + +❌ **Option B:** Circuit auto-closes after timeout +- Thousands of requests flood the still-broken service +- Circuit trips again immediately +- Creates flapping: OPEN → CLOSED → OPEN → CLOSED... + +✅ **HALF_OPEN solves this:** +- Test with just a FEW requests (e.g., 3) +- If they succeed → Service is healthy → Fully reopen +- If any fail → Service still broken → Wait longer +- Prevents flooding, enables controlled recovery + +--- + +### Q3: What's the difference between `timeWindow` and `intervalToHalfOpen`? + +**Completely different purposes:** + +**`timeWindow(60)`** = Memory span (ALWAYS active) +- How long to track failures/successes +- Creates a sliding 60-second window +- Old failures automatically expire +- Used in ALL states (CLOSED, OPEN, HALF_OPEN) +- Example: "Only count failures from last 60 seconds" + +**`intervalToHalfOpen(30)`** = Recovery cooldown (only when OPEN) +- How long to wait after circuit opens before testing +- Only used when circuit is OPEN +- Measured from when circuit opened (openedAt timestamp) +- Example: "Wait 30 seconds before trying recovery" + +**Think of it:** +- `timeWindow` = How far back you look when counting +- `intervalToHalfOpen` = How long you wait before testing recovery + +--- + +### Q4: Is `successThreshold(3)` tracked within the `timeWindow(60)`? + +**NO!** There are TWO types of success tracking: + +**1. Regular successes (CLOSED state)** - YES, tracked in timeWindow +``` +Used for calculating failure rate in Rate Strategy +Example: 10 requests = 6 failures + 4 successes (within 60s) +These successes ARE in timeWindow +``` + +**2. Half-open successes (HALF_OPEN state)** - NO, NOT in timeWindow +``` +Separate counter: halfOpenSuccessCount +Used ONLY to decide when to close circuit +NOT subject to timeWindow expiration +Resets to 0 on any failure +These successes are NOT in timeWindow +``` + +**Example:** +``` +State: HALF_OPEN +10:01:00 - Success ✓ (counter = 1) +10:01:05 - Success ✓ (counter = 2) +10:01:10 - Success ✓ (counter = 3) → CLOSE! + +Only took 10 seconds (not 60 seconds) +These are consecutive, immediate successes +No timeWindow involved here! +``` + +--- + +### Q5: When does the `intervalToHalfOpen` timer start? + +**It starts the moment the circuit trips to OPEN.** + +When circuit opens, we store `openedAt` timestamp. The timer counts from there. + +**Example:** +```php +->intervalToHalfOpen(30) // 30 seconds +``` + +``` +10:00:00 - Circuit trips to OPEN + openedAt = 10:00:00 ← Timer starts HERE + +10:00:10 - Request arrives + (10:00:10 - 10:00:00) = 10s < 30s + → Still in cooldown, BLOCKED + +10:00:30 - Request arrives + (10:00:30 - 10:00:00) = 30s >= 30s + → Cooldown finished, transition to HALF_OPEN +``` + +--- + +### Q6: Can I use different strategies for different services? + +**Yes!** Each client can have its own configuration: + +```php +class PaymentClient extends Client +{ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + // Strict - trip after just 3 failures + return CountStrategyConfig::make() + ->failureCountThreshold(3) + ->intervalToHalfOpen(120); // 2 min cooldown + } +} + +class EmailClient extends Client +{ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + // Flexible - trip at 50% failure rate + return RateStrategyConfig::make() + ->failureRateThreshold(50.0) + ->minimumRequests(10); + } +} +``` + +--- + +### Q7: How do failures expire from the timeWindow? + +**Automatically, based on TTL (Time To Live).** + +When a failure is recorded, it's stored with expiration = `timeWindow` seconds. + +**Example:** +```php +->timeWindow(60) // 60 seconds +``` + +``` +10:00:00 - Failure recorded + Stored in Redis/Cache with TTL = 60s + Will expire at 10:01:00 + +10:00:30 - Check failure count + Still counted (30s < 60s TTL) + +10:01:00 - Failure expires automatically + No longer counted + +10:01:30 - Check failure count + This failure is gone (expired) +``` + +Redis/Cache automatically removes expired keys. You don't need to manually clean up. + +--- + +### Q8: What happens if service fails during HALF_OPEN testing? + +**Circuit immediately trips back to OPEN.** + +``` +State: HALF_OPEN +Request 1: 200 ✓ (successCount = 1) +Request 2: 200 ✓ (successCount = 2) +Request 3: 500 ✗ FAILURE! + +Action: +→ Circuit goes back to OPEN 🔴 +→ Reset successCount to 0 +→ Set NEW openedAt timestamp +→ Must wait another intervalToHalfOpen (e.g., 30s) +→ Then try recovery again +``` + +**This prevents:** +- Premature recovery (service not fully healthy) +- Flapping between states +- Overwhelming fragile services + +--- + +### Q9: How do I monitor circuit breaker state? + +**Check storage directly or add logging:** + +```php +// Check state in Redis/Cache +$state = Cache::get('circuit_breaker:api.stripe.com:state'); +// Returns: "closed", "open", or "half_open" + +$failures = Cache::get('circuit_breaker:api.stripe.com:failures'); +``` + +Or add logging in your client: + +```php +try { + $response = $client->do($request); +} catch (CircuitOpenException $e) { + \Log::warning('Circuit open for service', [ + 'service' => $e->getService(), + 'opened_at' => Cache::get("circuit_breaker:{$e->getService()}:opened_at"), + ]); + + return ['error' => 'Service temporarily unavailable']; +} +``` + +--- + +### Q10: What status codes are considered failures? + +**By default: 5xx errors (500, 502, 503, 504)** + +You can customize: + +```php +RateStrategyConfig::make() + ->failureStatusCodes([500, 502, 503, 504, 429]); // Add 429 Too Many Requests +``` + +**Success = 2xx and 3xx** +**Failure = 4xx and 5xx (or custom list)** + +Note: Network errors and exceptions are always considered failures. + +--- + +## Best Practices + +1. **Use Rate Strategy for high-traffic services** (external APIs) +2. **Use Count Strategy for critical low-traffic services** (payments) +3. **Set appropriate timeWindow** - Too short = premature trips, too long = slow recovery +4. **Don't set intervalToHalfOpen too low** - Give service time to truly recover +5. **Use Redis for production** - Better performance than cache +6. **Monitor circuit state** - Log when circuits open/close +7. **Handle CircuitOpenException gracefully** - Return user-friendly errors +8. **Test your thresholds** - Start conservative, adjust based on metrics + +--- diff --git a/src/CircuitBreaker/CircuitBreaker.php b/src/CircuitBreaker/CircuitBreaker.php new file mode 100644 index 0000000..cef4064 --- /dev/null +++ b/src/CircuitBreaker/CircuitBreaker.php @@ -0,0 +1,180 @@ +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; + } + + public function getState(string $service): CircuitState + { + return $this->storage->getState($service); + } + + /** + * @template T + * @param callable(): T $callable + * @param callable(): T|null $fallback + * @return T + * @throws CircuitOpenException|Throwable + */ + public function call(string $service, callable $callable, ?callable $fallback = null): mixed + { + if (! $this->isAvailable($service)) { + return $fallback + ? $fallback() + : throw new CircuitOpenException($service, CircuitState::OPEN); + } + + try { + $result = $callable(); + $this->success($service); + return $result; + } catch (Throwable $e) { + $this->failure($service); + throw $e; + } + } + + 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); + } + + 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); + } + } + + public function recordHttpResult(string $service, int $statusCode): void + { + $this->isFailureStatusCode($statusCode) + ? $this->failure($service) + : $this->success($service); + } + + 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 */ + public function setFailureStatusCodes(array $statusCodes): self + { + $this->failureStatusCodes = $statusCodes; + return $this; + } + + /** @param int[] $statusCodes */ + public function setIgnoredStatusCodes(array $statusCodes): self + { + $this->ignoredStatusCodes = $statusCodes; + return $this; + } + + public function getStorage(): CircuitBreakerStorage + { + return $this->storage; + } + + public function getStrategy(): StrategyInterface + { + return $this->strategy; + } + + protected function transitionTo(string $service, CircuitState $newState): void + { + if ($this->getState($service) === $newState) { + return; + } + + $this->storage->setState($service, $newState); + + match ($newState) { + CircuitState::OPEN => $this->onOpen($service), + CircuitState::HALF_OPEN => $this->onHalfOpen($service), + CircuitState::CLOSED => $this->onClosed($service), + }; + } + + protected function onOpen(string $service): void + { + $this->storage->setOpenedAt($service, time()); + $this->storage->resetHalfOpenSuccess($service); + Logger::warning('CIRCUIT_BREAKER_TRIPPED', ['service' => $service]); + } + + protected function onHalfOpen(string $service): void + { + $this->storage->resetHalfOpenSuccess($service); + Logger::info('CIRCUIT_BREAKER_HALF_OPEN', ['service' => $service]); + } + + protected function onClosed(string $service): void + { + $this->storage->reset($service); + Logger::info('CIRCUIT_BREAKER_CLOSED', ['service' => $service]); + } +} \ No newline at end of file diff --git a/src/CircuitBreaker/CircuitBreakerFactory.php b/src/CircuitBreaker/CircuitBreakerFactory.php new file mode 100644 index 0000000..19ca4c5 --- /dev/null +++ b/src/CircuitBreaker/CircuitBreakerFactory.php @@ -0,0 +1,88 @@ +createStorageFromConfig($config); + $strategy = $this->createStrategyFromConfig($config); + + $circuitBreaker = new CircuitBreaker($storage, $strategy); + + // Set optional configurations + $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() ?? config('integrations.circuit_breaker.prefix', 'circuit_breaker'); + + 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..e730f86 --- /dev/null +++ b/src/CircuitBreaker/CircuitBreakerInterceptor.php @@ -0,0 +1,77 @@ +circuitBreaker = $circuitBreaker; + } + + + /** + * Check if the circuit allows the request (before middleware). + * + * @throws CircuitOpenException + */ + 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). + */ + 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. + */ + public function onException(IRequest $request, \Throwable $exception): void + { + $service = $this->resolveServiceName($request); + $this->circuitBreaker->failure($service); + } + + /** + * Resolve the service name from the request. + */ + 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..2f7bc04 --- /dev/null +++ b/src/CircuitBreaker/Config/CircuitBreakerConfig.php @@ -0,0 +1,184 @@ +timeWindow = $seconds; + return $this; + } + + /** + * Set the interval in seconds before attempting recovery (OPEN → HALF_OPEN). + * + * @param int $seconds Interval in seconds + * @return static + */ + public function intervalToHalfOpen(int $seconds): static + { + $this->intervalToHalfOpen = $seconds; + return $this; + } + + /** + * Set the number of consecutive successes needed to close the circuit. + * + * @param int $count Number of successes + * @return static + */ + public function successThreshold(int $count): static + { + $this->successThreshold = $count; + return $this; + } + + /** + * Set the storage adapter type ('redis' or 'cache'). + * + * @param string $type Storage 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 Cache key 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 Redis connection name + * @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 Cache store name + * @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 Array of HTTP status 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 Array of HTTP status codes + * @return static + */ + public function ignoredStatusCodes(array $codes): static + { + $this->ignoredStatusCodes = $codes; + return $this; + } + + // Getters + + 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; + } + + 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..bfd96f9 --- /dev/null +++ b/src/CircuitBreaker/Config/CountStrategyConfig.php @@ -0,0 +1,47 @@ +failureCountThreshold = $count; + return $this; + } + + public function getFailureCountThreshold(): int + { + return $this->failureCountThreshold; + } + + 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..b43c41f --- /dev/null +++ b/src/CircuitBreaker/Config/RateStrategyConfig.php @@ -0,0 +1,66 @@ +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 Minimum requests + * @return self + */ + public function minimumRequests(int $count): self + { + $this->minimumRequests = $count; + return $this; + } + + public function getFailureRateThreshold(): float + { + return $this->failureRateThreshold; + } + + public function getMinimumRequests(): int + { + 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..3c7eabc --- /dev/null +++ b/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php @@ -0,0 +1,73 @@ +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..357b91f --- /dev/null +++ b/src/CircuitBreaker/Storage/CacheStorage.php @@ -0,0 +1,136 @@ +cache = Cache::store($store); + $this->prefix = $prefix; + } + + protected function key(string $service, string $suffix): string + { + return "{$this->prefix}:{$service}:{$suffix}"; + } + + /** + * @throws InvalidArgumentException + */ + public function getState(string $service): CircuitState + { + $state = $this->cache->get($this->key($service, 'state')); + + return CircuitState::tryFrom($state ?: '') ?? CircuitState::CLOSED; + } + + 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); + } + } + + public function incrementFailure(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'failures'), $timeWindow); + } + + public function incrementSuccess(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'successes'), $timeWindow); + } + + protected function incrementWithExpiry(string $key, int $ttl): int + { + // Add key with TTL if it doesn't exist, then increment + $this->cache->add($key, 0, $ttl); + + return (int)$this->cache->increment($key); + } + + /** + * @throws InvalidArgumentException + */ + public function getFailureCount(string $service): int + { + return (int)$this->cache->get($this->key($service, 'failures'), 0); + } + + /** + * @throws InvalidArgumentException + */ + public function getSuccessCount(string $service): int + { + return (int)$this->cache->get($this->key($service, 'successes'), 0); + } + + /** + * @throws InvalidArgumentException + */ + public function getRequestCount(string $service): int + { + return $this->getFailureCount($service) + $this->getSuccessCount($service); + } + + 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)); + } + } + + /** + * @throws InvalidArgumentException + */ + public function getOpenedAt(string $service): ?int + { + $value = $this->cache->get($this->key($service, 'opened_at')); + + return $value ? (int)$value : null; + } + + public function setOpenedAt(string $service, int $timestamp): void + { + $this->cache->forever($this->key($service, 'opened_at'), $timestamp); + } + + /** + * @throws InvalidArgumentException + */ + public function getHalfOpenSuccessCount(string $service): int + { + return (int)$this->cache->get($this->key($service, 'half_open_successes'), 0); + } + + public function incrementHalfOpenSuccess(string $service): int + { + $key = $this->key($service, 'half_open_successes'); + + $this->cache->add($key, 0, Carbon::now()->addDay()); + + return (int)$this->cache->increment($key); + } + + 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..8102421 --- /dev/null +++ b/src/CircuitBreaker/Storage/RedisStorage.php @@ -0,0 +1,114 @@ +redis = Redis::connection($connection); + $this->prefix = $prefix; + } + + protected function key(string $service, string $suffix): string + { + return "{$this->prefix}:{$service}:{$suffix}"; + } + + public function getState(string $service): CircuitState + { + $state = $this->redis->get($this->key($service, 'state')); + + return CircuitState::tryFrom($state ?: '') ?? CircuitState::CLOSED; + } + + 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); + } + } + + public function incrementFailure(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'failures'), $timeWindow); + } + + public function incrementSuccess(string $service, int $timeWindow): int + { + return $this->incrementWithExpiry($this->key($service, 'successes'), $timeWindow); + } + + protected function incrementWithExpiry(string $key, int $ttl): int + { + $count = $this->redis->incr($key); + $this->redis->expire($key, $ttl); + + return (int) $count; + } + + public function getFailureCount(string $service): int + { + return (int) ($this->redis->get($this->key($service, 'failures')) ?: 0); + } + + public function getSuccessCount(string $service): int + { + return (int) ($this->redis->get($this->key($service, 'successes')) ?: 0); + } + + public function getRequestCount(string $service): int + { + return $this->getFailureCount($service) + $this->getSuccessCount($service); + } + + 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'), + ]); + } + + public function getOpenedAt(string $service): ?int + { + $value = $this->redis->get($this->key($service, 'opened_at')); + + return $value ? (int) $value : null; + } + + public function setOpenedAt(string $service, int $timestamp): void + { + $this->redis->set($this->key($service, 'opened_at'), $timestamp); + } + + public function getHalfOpenSuccessCount(string $service): int + { + return (int) ($this->redis->get($this->key($service, 'half_open_successes')) ?: 0); + } + + public function incrementHalfOpenSuccess(string $service): int + { + return (int) $this->redis->incr($this->key($service, 'half_open_successes')); + } + + 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..52e1128 --- /dev/null +++ b/src/CircuitBreaker/Strategy/CountStrategy.php @@ -0,0 +1,82 @@ +getFailureCount($service); + + return $failures >= $this->failureCountThreshold; + } + + public function shouldClose(string $service, CircuitBreakerStorage $storage): bool + { + $halfOpenSuccesses = $storage->getHalfOpenSuccessCount($service); + + return $halfOpenSuccesses >= $this->successThreshold; + } + + public function shouldAttemptReset(string $service, CircuitBreakerStorage $storage): bool + { + $openedAt = $storage->getOpenedAt($service); + + if ($openedAt === null) { + return true; + } + + return (time() - $openedAt) >= $this->intervalToHalfOpen; + } + + public function recordSuccess(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementSuccess($service, $this->timeWindow); + } + + public function recordFailure(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementFailure($service, $this->timeWindow); + } + + public function getTimeWindow(): int + { + return $this->timeWindow; + } + + public function getIntervalToHalfOpen(): int + { + return $this->intervalToHalfOpen; + } + + public function getMinimumRequests(): int + { + return 0; // Count strategy doesn't require minimum requests + } + + public function getSuccessThreshold(): int + { + return $this->successThreshold; + } + + public function getFailureCountThreshold(): int + { + return $this->failureCountThreshold; + } +} diff --git a/src/CircuitBreaker/Strategy/RateStrategy.php b/src/CircuitBreaker/Strategy/RateStrategy.php new file mode 100644 index 0000000..52f1047 --- /dev/null +++ b/src/CircuitBreaker/Strategy/RateStrategy.php @@ -0,0 +1,83 @@ +getRequestCount($service); + + if ($totalRequests < $this->minimumRequests) { + return false; + } + + $failureRate = ($storage->getFailureCount($service) / $totalRequests) * 100; + + return $failureRate >= $this->failureRateThreshold; + } + + public function shouldClose(string $service, CircuitBreakerStorage $storage): bool + { + return $storage->getHalfOpenSuccessCount($service) >= $this->successThreshold; + } + + public function shouldAttemptReset(string $service, CircuitBreakerStorage $storage): bool + { + $openedAt = $storage->getOpenedAt($service); + + return $openedAt === null || (time() - $openedAt) >= $this->intervalToHalfOpen; + } + + public function recordSuccess(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementSuccess($service, $this->timeWindow); + } + + public function recordFailure(string $service, CircuitBreakerStorage $storage): void + { + $storage->incrementFailure($service, $this->timeWindow); + } + + public function getTimeWindow(): int + { + return $this->timeWindow; + } + + public function getIntervalToHalfOpen(): int + { + return $this->intervalToHalfOpen; + } + + public function getMinimumRequests(): int + { + return $this->minimumRequests; + } + + public function getSuccessThreshold(): int + { + return $this->successThreshold; + } + + public function getFailureRateThreshold(): float + { + return $this->failureRateThreshold; + } +} \ No newline at end of file diff --git a/src/Client.php b/src/Client.php index d4461f9..75a14f1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,199 +1,272 @@ -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(); - $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 resolveMethod($method): string - { - if (is_string($method)) return strtoupper($method); - if (is_object($method)) { - if (method_exists($method, 'value')) return strtoupper((string) $method->value); - if (method_exists($method, 'name')) return strtoupper((string) $method->name); - if (is_callable($method)) return strtoupper((string) $method()); - if (method_exists($method, '__toString')) return strtoupper((string) $method); - } - return 'GET'; - } - - 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(); + + // Auto-configure circuit breaker if config is provided + $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 = $this->resolveMethod($request->method()); + $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, $e); + 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 resolveMethod($method): string + { + if (is_string($method)) return strtoupper($method); + if (is_object($method)) { + if (method_exists($method, 'value')) return strtoupper((string) $method->value); + if (method_exists($method, 'name')) return strtoupper((string) $method->name); + if (is_callable($method)) return strtoupper((string) $method()); + if (method_exists($method, '__toString')) return strtoupper((string) $method); + } + return 'GET'; + } + + 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); + } + + /** + * Configure circuit breaker for this client. + * Override this method in subclasses to provide custom configuration. + * + * @return CircuitBreakerConfig|null Circuit breaker configuration or null to use global default + */ + protected function circuitBreakerConfig(): ?CircuitBreakerConfig + { + return null; // Default: no custom circuit breaker + } + + /** + * Auto-configure circuit breaker from config if provided. + * + * @return void + */ + protected function configureCircuitBreakerFromConfig(): void + { + $config = $this->circuitBreakerConfig(); + + // Priority 1: Custom config (highest priority) + if ($config !== null) { + $factory = app(CircuitBreakerFactory::class); + $circuitBreaker = $factory->createFromConfig($config); + $this->withCircuitBreaker($circuitBreaker); + return; + } + + // Priority 2: Per-service enabled flag + if ($this->circuitBreakerEnabled) { + try { + $globalCircuitBreaker = app(CircuitBreaker::class); + $this->withCircuitBreaker($globalCircuitBreaker); + } catch (\Throwable $e) { + // Silently ignore if circuit breaker dependencies are not available + } + } + + // No global config check - circuit breaker is always per-service controlled + } + + 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 @@ -mergeConfigFrom(__DIR__.'/../config/integrations.php', 'integrations'); - - $this->app->bind(Transport::class, function ($app) { - $headers = config('integrations.default_headers', []); - $timeout = config('integrations.timeout', null); - $retry = config('integrations.retry.times', 0); - $sleep = config('integrations.retry.sleep_ms', 0); - - return (new LaravelHttpTransport($headers, $timeout, $retry, $sleep)); - }); - - $this->app->bind(ResponseMapperInterface::class, DefaultResponseMapper::class); - } - - public function boot(): void - { - $this->publishes([ - __DIR__.'/../config/integrations.php' => config_path('integrations.php'), - ], 'config'); - } -} +mergeConfigFrom(__DIR__.'/../config/integrations.php', 'integrations'); + + $this->app->bind(Transport::class, function ($app) { + $headers = config('integrations.default_headers', []); + $timeout = config('integrations.timeout', null); + $retry = config('integrations.retry.times', 0); + $sleep = config('integrations.retry.sleep_ms', 0); + + return (new LaravelHttpTransport($headers, $timeout, $retry, $sleep)); + }); + + $this->app->bind(ResponseMapperInterface::class, DefaultResponseMapper::class); + + $this->registerCircuitBreaker(); + } + + protected function registerCircuitBreaker(): void + { + // Register circuit breaker factory for per-service configuration + $this->app->singleton(CircuitBreakerFactory::class, function () { + return new CircuitBreakerFactory(); + }); + + // Register global circuit breaker singleton as fallback + // Used only when circuitBreakerConfig() is not overridden and enabled in config + $this->app->singleton(CircuitBreaker::class, function ($app) { + $config = config('integrations.circuit_breaker'); + + // Create storage adapter + $storage = $this->createStorageFromConfig($config); + + // Create strategy + $strategy = match ($config['strategy']) { + 'count' => new CountStrategy( + $config['time_window'], + $config['failure_count_threshold'], + $config['interval_to_half_open'], + $config['success_threshold'] + ), + default => new RateStrategy( + $config['time_window'], + $config['failure_rate_threshold'], + $config['minimum_requests'], + $config['interval_to_half_open'], + $config['success_threshold'] + ), + }; + + return (new CircuitBreaker($storage, $strategy)) + ->setFailureStatusCodes($config['failure_status_codes'] ?? []) + ->setIgnoredStatusCodes($config['ignored_status_codes'] ?? []); + }); + } + + protected function createStorageFromConfig(array $config): CircuitBreakerStorage + { + if ($config['storage'] === 'redis') { + try { + return new RedisStorage($config['prefix'], $config['redis_connection'] ?? null); + } catch (\Throwable $e) { + Log::warning('Circuit breaker: Redis unavailable, falling back to cache', [ + 'error' => $e->getMessage(), + ]); + } + } + return new CacheStorage($config['prefix'], $config['cache_store'] ?? null); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../config/integrations.php' => config_path('integrations.php'), + ], 'config'); + } +} From 3079c71d76dfe1de4cf3ca1ee2401f4bf6bbc9fd Mon Sep 17 00:00:00 2001 From: Bilal-Elhalawaty Date: Mon, 29 Dec 2025 17:35:42 +0200 Subject: [PATCH 2/3] refactor: update circuit breaker configuration and improve validation checks --- config/integrations.php | 31 +- docs/CIRCUIT_BREAKER.md | 628 ------------------ src/CircuitBreaker/CircuitBreaker.php | 126 ++-- src/CircuitBreaker/CircuitBreakerFactory.php | 8 +- .../CircuitBreakerInterceptor.php | 9 +- .../Config/CircuitBreakerConfig.php | 47 +- .../Config/CountStrategyConfig.php | 26 +- .../Config/RateStrategyConfig.php | 34 +- .../Contracts/CircuitBreakerStorage.php | 21 + .../Contracts/StrategyInterface.php | 30 +- .../Exceptions/CircuitOpenException.php | 5 + src/CircuitBreaker/Storage/CacheStorage.php | 68 +- src/CircuitBreaker/Storage/RedisStorage.php | 89 ++- src/CircuitBreaker/Strategy/CountStrategy.php | 54 +- src/CircuitBreaker/Strategy/RateStrategy.php | 53 +- src/Client.php | 18 +- src/IntegrationsServiceProvider.php | 140 ++-- 17 files changed, 513 insertions(+), 874 deletions(-) delete mode 100644 docs/CIRCUIT_BREAKER.md diff --git a/config/integrations.php b/config/integrations.php index da93fd1..bf1be38 100644 --- a/config/integrations.php +++ b/config/integrations.php @@ -13,31 +13,18 @@ /* |-------------------------------------------------------------------------- - | Circuit Breaker Configuration (Default Settings) + | Circuit Breaker Storage Prefix |-------------------------------------------------------------------------- | - | Default circuit breaker settings that services can opt-in to use. + | Storage key prefix for circuit breaker state (app-namespaced). + | This ensures multiple applications can share the same Redis/Cache + | without key collisions. | - | Per-service control: - | 1. Custom config: Override circuitBreakerConfig() method for custom settings - | 2. Default settings: Set $circuitBreakerEnabled = true to use these defaults - | 3. Disabled: Default behavior (no override needed) - | - | See: docs/CIRCUIT_BREAKER_PER_SERVICE.md + | Note: APP_NAME is sanitized to replace ':' with '_' to prevent key parsing issues. | */ - 'circuit_breaker' => [ - 'storage' => env('INTEGRATIONS_CIRCUIT_BREAKER_STORAGE', 'cache'), - 'cache_store' => env('INTEGRATIONS_CIRCUIT_BREAKER_CACHE_STORE', null), - 'prefix' => env('INTEGRATIONS_CIRCUIT_BREAKER_PREFIX', 'circuit_breaker'), - 'strategy' => env('INTEGRATIONS_CIRCUIT_BREAKER_STRATEGY', 'rate'), - 'time_window' => env('INTEGRATIONS_CIRCUIT_BREAKER_TIME_WINDOW', 60), - 'failure_rate_threshold' => env('INTEGRATIONS_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD', 50), - 'failure_count_threshold' => env('INTEGRATIONS_CIRCUIT_BREAKER_FAILURE_COUNT_THRESHOLD', 5), - 'minimum_requests' => env('INTEGRATIONS_CIRCUIT_BREAKER_MINIMUM_REQUESTS', 10), - 'interval_to_half_open' => env('INTEGRATIONS_CIRCUIT_BREAKER_INTERVAL_TO_HALF_OPEN', 30), - 'success_threshold' => env('INTEGRATIONS_CIRCUIT_BREAKER_SUCCESS_THRESHOLD', 3), - 'failure_status_codes' => [500, 502, 503, 504], - 'ignored_status_codes' => [], - ], + '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 deleted file mode 100644 index 9e4dff8..0000000 --- a/docs/CIRCUIT_BREAKER.md +++ /dev/null @@ -1,628 +0,0 @@ -# Circuit Breaker - -## What Is It? - -Protects your application from cascading failures when external services become unreliable. Automatically stops requests to failing services and gives them time to recover. - -**What "Trip" Means:** -- **Trip** = Circuit transitions from CLOSED (normal) to OPEN (blocking requests) -- When circuit "trips", it opens and blocks all requests to protect your system -- After a cooldown period, it tests if the service recovered - ---- - -## Three Circuit States - -``` -CLOSED 🟢 (Normal - all requests allowed) - ↓ - Too many failures detected - ↓ -OPEN 🔴 (Service down - block ALL requests) - ↓ - Wait for cooldown period (e.g., 30s) - ↓ -HALF_OPEN ⚠️ (Testing - allow FEW requests to test recovery) - ↓ - If 3 successes → CLOSED 🟢 (recovered!) - If 1 failure → OPEN 🔴 (still broken, wait longer) -``` - -### Why HALF_OPEN is Needed - -Without HALF_OPEN, you'd have two bad options: -- ❌ **Stay OPEN forever** → Never recover, block healthy services -- ❌ **Auto-CLOSE immediately** → Flood failing service with thousands of requests - -**HALF_OPEN = Controlled Testing** -- After cooldown (e.g., 30s), test with just a FEW requests (e.g., 3) -- If those succeed → Service is healthy → Fully reopen -- If any fail → Service still broken → Wait longer - ---- - -## How to Use - -### Option 1: Custom Configuration Per Service - -```php -use Idaratech\Integrations\Client; -use Idaratech\Integrations\CircuitBreaker\Config\RateStrategyConfig; - -class PaymentClient extends Client -{ - protected function circuitBreakerConfig(): ?CircuitBreakerConfig - { - return RateStrategyConfig::make() - ->failureRateThreshold(40.0) // Trip at 40% failure rate - ->minimumRequests(10) // Need 10 requests minimum - ->timeWindow(60) // Track failures in last 60s - ->intervalToHalfOpen(30) // Wait 30s before testing recovery - ->successThreshold(3) // Need 3 successes to close - ->storage('redis'); - } -} -``` - -### Option 2: Count Strategy (Absolute Failures) - -```php -use Idaratech\Integrations\CircuitBreaker\Config\CountStrategyConfig; - -class EmailClient extends Client -{ - protected function circuitBreakerConfig(): ?CircuitBreakerConfig - { - return CountStrategyConfig::make() - ->failureCountThreshold(5) // Trip after 5 failures - ->intervalToHalfOpen(120) // Wait 2 minutes - ->storage('redis'); - } -} -``` - ---- - -## Strategies Explained - -### Rate Strategy (Percentage-Based) - -Trips when **failure percentage** exceeds threshold. - -**Formula:** -``` -failureRate = (failures / totalRequests) × 100 - -if totalRequests >= minimumRequests AND failureRate >= threshold: - TRIP → OPEN -``` - -**Example:** -```php -RateStrategyConfig::make() - ->failureRateThreshold(50.0) // 50% threshold - ->minimumRequests(10); // Need at least 10 requests -``` - -**Scenarios:** -``` -Scenario 1: Not enough data -Total: 8 requests, 5 failures (62.5% failure rate) -Result: DON'T TRIP (8 < 10 minimum requests) - -Scenario 2: Below threshold -Total: 20 requests, 8 failures (40% failure rate) -Result: DON'T TRIP (40% < 50%) - -Scenario 3: Exceeds threshold - TRIP! -Total: 20 requests, 11 failures (55% failure rate) -Result: TRIP! (55% >= 50% AND 20 >= 10) -``` - -**Best for:** High-traffic services, external APIs - ---- - -### Count Strategy (Absolute Count) - -Trips when **absolute failure count** exceeds threshold. - -**Formula:** -``` -if failures >= threshold: - TRIP → OPEN -``` - -**Example:** -```php -CountStrategyConfig::make() - ->failureCountThreshold(5); // Trip after 5 failures -``` - -**Scenarios:** -``` -Scenario 1: Below threshold -Failures: 3, Successes: 100 -Result: DON'T TRIP (3 < 5) - -Scenario 2: Reaches threshold - TRIP! -Failures: 5, Successes: 2 -Result: TRIP! (5 >= 5) -``` - -**Best for:** Critical services (payments), low-traffic services, strict failure policies - ---- - -### Strategy Comparison - -| Aspect | Rate Strategy | Count Strategy | -|--------|--------------|----------------| -| **Trips based on** | Percentage (failures/total) | Absolute number | -| **Requires minimum requests** | Yes | No | -| **Formula** | `(failures/total)×100 >= 50%` | `failures >= 5` | -| **Best for** | High traffic, variable load | Critical systems, low traffic | -| **Example** | 11 failures out of 20 = 55% | 5 failures (regardless of total) | - ---- - -## Configuration Parameters Explained - -### 1. `timeWindow` - Memory Span (ALWAYS Active) - -**What it does:** Defines how long to "remember" failures and successes - -**Always running:** Tracks in ALL states (CLOSED, OPEN, HALF_OPEN) - -**Example:** -```php -->timeWindow(60) // Track failures in last 60 seconds -``` - -**How it works:** -``` -Current time: 10:01:00 - -Failures in storage: -09:59:50 - Failure ❌ EXPIRED (outside 60s window) -10:00:05 - Failure ✓ COUNTED (within 60s) -10:00:30 - Failure ✓ COUNTED (within 60s) -10:00:50 - Failure ✓ COUNTED (within 60s) - -Only count: 3 failures (last 60 seconds) -Old failures automatically expire! -``` - -**Purpose:** -- Prevents old failures from affecting current decisions -- Keeps circuit breaker responsive to current service health -- Creates a "sliding window" that moves with time - ---- - -### 2. `intervalToHalfOpen` - Recovery Cooldown (Only When OPEN) - -**What it does:** How long to wait after circuit opens before testing recovery - -**Only active:** When circuit is OPEN - -**Example:** -```php -->intervalToHalfOpen(30) // Wait 30 seconds before testing -``` - -**How it works:** -``` -10:00:00 - Circuit OPENS - Start timer from HERE - -10:00:10 - Request arrives - 10s < 30s → BLOCKED ⛔ - -10:00:30 - Request arrives - 30s >= 30s → Transition to HALF_OPEN ✓ - Request ALLOWED (testing recovery) -``` - -**Purpose:** -- Gives failing service time to recover -- Prevents immediate retry attempts - ---- - -### 3. `successThreshold` - Recovery Test (Only in HALF_OPEN) - -**What it does:** How many CONSECUTIVE successes needed to close circuit - -**Only active:** When circuit is HALF_OPEN - -**NOT tracked in timeWindow!** Separate counter, resets on any failure. - -**Example:** -```php -->successThreshold(3) // Need 3 consecutive successes -``` - -**How it works:** -``` -State: HALF_OPEN - -Request 1: 200 ✓ → successCount = 1 -Request 2: 200 ✓ → successCount = 2 -Request 3: 200 ✓ → successCount = 3 -→ Circuit CLOSES! 🟢 - -Alternative (failure during test): -Request 1: 200 ✓ → successCount = 1 -Request 2: 500 ✗ → FAILURE! -→ Circuit goes back to OPEN 🔴 -→ Reset counter to 0 -→ Wait another 30s before retrying -``` - -**Purpose:** -- Ensures service is truly recovered (not just one lucky success) -- Prevents flapping (OPEN → CLOSED → OPEN) - ---- - -### Visual Summary of Parameters - -``` -timeWindow(60) - ALWAYS SLIDING -├─────────────────────────────────────────────────────┤ -09:59:50 10:00:00 10:00:30 10:01:00 - ❌ ✓ ✓ NOW -expired counted counted -└──────────── Only count last 60s ────────────┘ - - -intervalToHalfOpen(30) - ONLY WHEN OPEN -Circuit OPENS ├─────────────┤ Test recovery - 10:00:00 10:00:30 - ↓ - HALF_OPEN - - -successThreshold(3) - ONLY IN HALF_OPEN -HALF_OPEN: ✓ ✓ ✓ → CLOSED -HALF_OPEN: ✓ ✗ → Back to OPEN - └─ Reset counter -``` - ---- - -## Complete Lifecycle Example - -**Configuration:** -```php -RateStrategyConfig::make() - ->failureRateThreshold(50.0) // 50% failures - ->minimumRequests(10) - ->timeWindow(60) // Track last 60s - ->intervalToHalfOpen(30) // Wait 30s before testing - ->successThreshold(3); // Need 3 successes -``` - -**Timeline:** - -``` -State: CLOSED 🟢 -════════════════════════════════════════════════════════ -10:00:00-10:00:40 - 9 requests (4 success, 5 failures) - 9 < 10 minimum → Don't trip yet - -10:00:45 - Request 10: 500 ✗ - Total: 10 requests (4 success, 6 failures) - Failure rate: (6/10) × 100 = 60% - 60% >= 50% threshold → TRIP! 🔴 - -State: OPEN 🔴 (openedAt = 10:00:45) -════════════════════════════════════════════════════════ -10:00:50 - Request arrives - Time since opened: 5s < 30s - → BLOCKED ⛔ (throw CircuitOpenException) - -10:01:00 - Request arrives - 15s < 30s → BLOCKED ⛔ - -10:01:15 - Request arrives (30s passed!) - 30s >= 30s → Transition to HALF_OPEN ⚠️ - → Request ALLOWED (testing) - -State: HALF_OPEN ⚠️ (testing recovery) -════════════════════════════════════════════════════════ -10:01:15 - Request 1: 200 ✓ (successCount = 1/3) -10:01:20 - Request 2: 200 ✓ (successCount = 2/3) -10:01:25 - Request 3: 200 ✓ (successCount = 3/3) - 3 >= 3 → Circuit CLOSES! 🟢 - -State: CLOSED 🟢 (service recovered!) -════════════════════════════════════════════════════════ -10:01:30 - Back to normal operation - Start tracking new failures in timeWindow -``` - ---- - -## Storage - -Circuit state is stored per service using Redis or Cache. - -**Key Structure:** -``` -circuit_breaker:{service}:state → "closed", "open", "half_open" -circuit_breaker:{service}:failures → 3 -circuit_breaker:{service}:successes → 15 -circuit_breaker:{service}:opened_at → 1703520000 -circuit_breaker:{service}:half_open_successes → 2 -``` - -**Multiple Services:** -``` -circuit_breaker:api.stripe.com:state → "open" (down) -circuit_breaker:api.twilio.com:state → "closed" (working) -circuit_breaker:payment.internal:state → "half_open" (testing) -``` - ---- - -## FAQ - Common Questions - -### Q1: What does "trip" mean? - -**Trip** means the circuit breaker transitions from CLOSED to OPEN state. -- Circuit "trips" when too many failures detected -- Once tripped, circuit is OPEN and blocks all requests -- Protects your system from cascading failures - ---- - -### Q2: Why do we need HALF_OPEN state? Can't we just go CLOSED → OPEN → CLOSED? - -**Without HALF_OPEN you'd have problems:** - -❌ **Option A:** Circuit stays OPEN forever -- Service recovers but circuit never reopens -- You're blocking requests to a healthy service - -❌ **Option B:** Circuit auto-closes after timeout -- Thousands of requests flood the still-broken service -- Circuit trips again immediately -- Creates flapping: OPEN → CLOSED → OPEN → CLOSED... - -✅ **HALF_OPEN solves this:** -- Test with just a FEW requests (e.g., 3) -- If they succeed → Service is healthy → Fully reopen -- If any fail → Service still broken → Wait longer -- Prevents flooding, enables controlled recovery - ---- - -### Q3: What's the difference between `timeWindow` and `intervalToHalfOpen`? - -**Completely different purposes:** - -**`timeWindow(60)`** = Memory span (ALWAYS active) -- How long to track failures/successes -- Creates a sliding 60-second window -- Old failures automatically expire -- Used in ALL states (CLOSED, OPEN, HALF_OPEN) -- Example: "Only count failures from last 60 seconds" - -**`intervalToHalfOpen(30)`** = Recovery cooldown (only when OPEN) -- How long to wait after circuit opens before testing -- Only used when circuit is OPEN -- Measured from when circuit opened (openedAt timestamp) -- Example: "Wait 30 seconds before trying recovery" - -**Think of it:** -- `timeWindow` = How far back you look when counting -- `intervalToHalfOpen` = How long you wait before testing recovery - ---- - -### Q4: Is `successThreshold(3)` tracked within the `timeWindow(60)`? - -**NO!** There are TWO types of success tracking: - -**1. Regular successes (CLOSED state)** - YES, tracked in timeWindow -``` -Used for calculating failure rate in Rate Strategy -Example: 10 requests = 6 failures + 4 successes (within 60s) -These successes ARE in timeWindow -``` - -**2. Half-open successes (HALF_OPEN state)** - NO, NOT in timeWindow -``` -Separate counter: halfOpenSuccessCount -Used ONLY to decide when to close circuit -NOT subject to timeWindow expiration -Resets to 0 on any failure -These successes are NOT in timeWindow -``` - -**Example:** -``` -State: HALF_OPEN -10:01:00 - Success ✓ (counter = 1) -10:01:05 - Success ✓ (counter = 2) -10:01:10 - Success ✓ (counter = 3) → CLOSE! - -Only took 10 seconds (not 60 seconds) -These are consecutive, immediate successes -No timeWindow involved here! -``` - ---- - -### Q5: When does the `intervalToHalfOpen` timer start? - -**It starts the moment the circuit trips to OPEN.** - -When circuit opens, we store `openedAt` timestamp. The timer counts from there. - -**Example:** -```php -->intervalToHalfOpen(30) // 30 seconds -``` - -``` -10:00:00 - Circuit trips to OPEN - openedAt = 10:00:00 ← Timer starts HERE - -10:00:10 - Request arrives - (10:00:10 - 10:00:00) = 10s < 30s - → Still in cooldown, BLOCKED - -10:00:30 - Request arrives - (10:00:30 - 10:00:00) = 30s >= 30s - → Cooldown finished, transition to HALF_OPEN -``` - ---- - -### Q6: Can I use different strategies for different services? - -**Yes!** Each client can have its own configuration: - -```php -class PaymentClient extends Client -{ - protected function circuitBreakerConfig(): ?CircuitBreakerConfig - { - // Strict - trip after just 3 failures - return CountStrategyConfig::make() - ->failureCountThreshold(3) - ->intervalToHalfOpen(120); // 2 min cooldown - } -} - -class EmailClient extends Client -{ - protected function circuitBreakerConfig(): ?CircuitBreakerConfig - { - // Flexible - trip at 50% failure rate - return RateStrategyConfig::make() - ->failureRateThreshold(50.0) - ->minimumRequests(10); - } -} -``` - ---- - -### Q7: How do failures expire from the timeWindow? - -**Automatically, based on TTL (Time To Live).** - -When a failure is recorded, it's stored with expiration = `timeWindow` seconds. - -**Example:** -```php -->timeWindow(60) // 60 seconds -``` - -``` -10:00:00 - Failure recorded - Stored in Redis/Cache with TTL = 60s - Will expire at 10:01:00 - -10:00:30 - Check failure count - Still counted (30s < 60s TTL) - -10:01:00 - Failure expires automatically - No longer counted - -10:01:30 - Check failure count - This failure is gone (expired) -``` - -Redis/Cache automatically removes expired keys. You don't need to manually clean up. - ---- - -### Q8: What happens if service fails during HALF_OPEN testing? - -**Circuit immediately trips back to OPEN.** - -``` -State: HALF_OPEN -Request 1: 200 ✓ (successCount = 1) -Request 2: 200 ✓ (successCount = 2) -Request 3: 500 ✗ FAILURE! - -Action: -→ Circuit goes back to OPEN 🔴 -→ Reset successCount to 0 -→ Set NEW openedAt timestamp -→ Must wait another intervalToHalfOpen (e.g., 30s) -→ Then try recovery again -``` - -**This prevents:** -- Premature recovery (service not fully healthy) -- Flapping between states -- Overwhelming fragile services - ---- - -### Q9: How do I monitor circuit breaker state? - -**Check storage directly or add logging:** - -```php -// Check state in Redis/Cache -$state = Cache::get('circuit_breaker:api.stripe.com:state'); -// Returns: "closed", "open", or "half_open" - -$failures = Cache::get('circuit_breaker:api.stripe.com:failures'); -``` - -Or add logging in your client: - -```php -try { - $response = $client->do($request); -} catch (CircuitOpenException $e) { - \Log::warning('Circuit open for service', [ - 'service' => $e->getService(), - 'opened_at' => Cache::get("circuit_breaker:{$e->getService()}:opened_at"), - ]); - - return ['error' => 'Service temporarily unavailable']; -} -``` - ---- - -### Q10: What status codes are considered failures? - -**By default: 5xx errors (500, 502, 503, 504)** - -You can customize: - -```php -RateStrategyConfig::make() - ->failureStatusCodes([500, 502, 503, 504, 429]); // Add 429 Too Many Requests -``` - -**Success = 2xx and 3xx** -**Failure = 4xx and 5xx (or custom list)** - -Note: Network errors and exceptions are always considered failures. - ---- - -## Best Practices - -1. **Use Rate Strategy for high-traffic services** (external APIs) -2. **Use Count Strategy for critical low-traffic services** (payments) -3. **Set appropriate timeWindow** - Too short = premature trips, too long = slow recovery -4. **Don't set intervalToHalfOpen too low** - Give service time to truly recover -5. **Use Redis for production** - Better performance than cache -6. **Monitor circuit state** - Log when circuits open/close -7. **Handle CircuitOpenException gracefully** - Return user-friendly errors -8. **Test your thresholds** - Start conservative, adjust based on metrics - ---- diff --git a/src/CircuitBreaker/CircuitBreaker.php b/src/CircuitBreaker/CircuitBreaker.php index cef4064..a70e467 100644 --- a/src/CircuitBreaker/CircuitBreaker.php +++ b/src/CircuitBreaker/CircuitBreaker.php @@ -2,12 +2,12 @@ namespace Idaratech\Integrations\CircuitBreaker; +use Carbon\Carbon; use Idaratech\Integrations\CircuitBreaker\Contracts\CircuitBreakerStorage; use Idaratech\Integrations\CircuitBreaker\Contracts\StrategyInterface; use Idaratech\Integrations\CircuitBreaker\Enums\CircuitState; -use Idaratech\Integrations\CircuitBreaker\Exceptions\CircuitOpenException; use Idaratech\Integrations\Logger; -use Throwable; +use Psr\SimpleCache\InvalidArgumentException; class CircuitBreaker { @@ -17,11 +17,20 @@ class CircuitBreaker /** @var int[] */ protected array $ignoredStatusCodes = []; + /** + * @param CircuitBreakerStorage $storage + * @param StrategyInterface $strategy + */ public function __construct( protected readonly CircuitBreakerStorage $storage, protected readonly StrategyInterface $strategy ) {} + /** + * @param string $service + * @return bool + * @throws InvalidArgumentException + */ public function isAvailable(string $service): bool { $state = $this->getState($service); @@ -38,36 +47,21 @@ public function isAvailable(string $service): bool return false; } + /** + * @param string $service + * @return CircuitState + * @throws InvalidArgumentException + */ public function getState(string $service): CircuitState { return $this->storage->getState($service); } /** - * @template T - * @param callable(): T $callable - * @param callable(): T|null $fallback - * @return T - * @throws CircuitOpenException|Throwable + * @param string $service + * @return void + * @throws InvalidArgumentException */ - public function call(string $service, callable $callable, ?callable $fallback = null): mixed - { - if (! $this->isAvailable($service)) { - return $fallback - ? $fallback() - : throw new CircuitOpenException($service, CircuitState::OPEN); - } - - try { - $result = $callable(); - $this->success($service); - return $result; - } catch (Throwable $e) { - $this->failure($service); - throw $e; - } - } - public function success(string $service): void { $state = $this->getState($service); @@ -84,6 +78,11 @@ public function success(string $service): void $this->strategy->recordSuccess($service, $this->storage); } + /** + * @param string $service + * @return void + * @throws InvalidArgumentException + */ public function failure(string $service): void { $state = $this->getState($service); @@ -100,6 +99,12 @@ public function failure(string $service): void } } + /** + * @param string $service + * @param int $statusCode + * @return void + * @throws InvalidArgumentException + */ public function recordHttpResult(string $service, int $statusCode): void { $this->isFailureStatusCode($statusCode) @@ -107,6 +112,10 @@ public function recordHttpResult(string $service, int $statusCode): void : $this->success($service); } + /** + * @param int $statusCode + * @return bool + */ public function isFailureStatusCode(int $statusCode): bool { if (in_array($statusCode, $this->ignoredStatusCodes, true)) { @@ -120,37 +129,44 @@ public function isFailureStatusCode(int $statusCode): bool return $statusCode >= 500; } - /** @param int[] $statusCodes */ + /** + * @param int[] $statusCodes + * @return self + */ public function setFailureStatusCodes(array $statusCodes): self { $this->failureStatusCodes = $statusCodes; return $this; } - /** @param int[] $statusCodes */ + /** + * @param int[] $statusCodes + * @return self + */ public function setIgnoredStatusCodes(array $statusCodes): self { $this->ignoredStatusCodes = $statusCodes; return $this; } - public function getStorage(): CircuitBreakerStorage - { - return $this->storage; - } - - public function getStrategy(): StrategyInterface - { - return $this->strategy; - } - + /** + * @param string $service + * @param CircuitState $newState + * @return void + * @throws InvalidArgumentException + */ protected function transitionTo(string $service, CircuitState $newState): void { if ($this->getState($service) === $newState) { return; } - $this->storage->setState($service, $newState); + // 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), @@ -159,22 +175,50 @@ protected function transitionTo(string $service, CircuitState $newState): void }; } + /** + * @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]); + + 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]); + + 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]); + + 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 index 19ca4c5..3a5c50a 100644 --- a/src/CircuitBreaker/CircuitBreakerFactory.php +++ b/src/CircuitBreaker/CircuitBreakerFactory.php @@ -12,15 +12,12 @@ use Idaratech\Integrations\CircuitBreaker\Strategy\CountStrategy; use Idaratech\Integrations\CircuitBreaker\Strategy\RateStrategy; -/** - * Factory for creating circuit breaker instances from configuration objects. - */ class CircuitBreakerFactory { /** * Create a circuit breaker from a configuration object. * - * @param CircuitBreakerConfig $config Configuration object + * @param CircuitBreakerConfig $config * @return CircuitBreaker */ public function createFromConfig(CircuitBreakerConfig $config): CircuitBreaker @@ -30,7 +27,6 @@ public function createFromConfig(CircuitBreakerConfig $config): CircuitBreaker $circuitBreaker = new CircuitBreaker($storage, $strategy); - // Set optional configurations $circuitBreaker->setFailureStatusCodes($config->getFailureStatusCodes()); $circuitBreaker->setIgnoredStatusCodes($config->getIgnoredStatusCodes()); @@ -46,7 +42,7 @@ public function createFromConfig(CircuitBreakerConfig $config): CircuitBreaker protected function createStorageFromConfig(CircuitBreakerConfig $config): CircuitBreakerStorage { $storageType = $config->getStorage(); - $prefix = $config->getPrefix() ?? config('integrations.circuit_breaker.prefix', 'circuit_breaker'); + $prefix = $config->getPrefix(); if ($storageType === 'redis') { return new RedisStorage($prefix, $config->getRedisConnection()); diff --git a/src/CircuitBreaker/CircuitBreakerInterceptor.php b/src/CircuitBreaker/CircuitBreakerInterceptor.php index e730f86..8caf304 100644 --- a/src/CircuitBreaker/CircuitBreakerInterceptor.php +++ b/src/CircuitBreaker/CircuitBreakerInterceptor.php @@ -5,6 +5,7 @@ use Idaratech\Integrations\CircuitBreaker\Exceptions\CircuitOpenException; use Idaratech\Integrations\Contracts\IRequest; use Idaratech\Integrations\Contracts\IResponse; +use Psr\SimpleCache\InvalidArgumentException; class CircuitBreakerInterceptor { @@ -20,6 +21,7 @@ public function __construct(CircuitBreaker $circuitBreaker) * Check if the circuit allows the request (before middleware). * * @throws CircuitOpenException + * @throws InvalidArgumentException */ public function before(IRequest $request): void { @@ -35,6 +37,7 @@ public function before(IRequest $request): void /** * Record the result after the request completes (after middleware). + * @throws InvalidArgumentException */ public function after(IRequest $request, IResponse $response): void { @@ -46,8 +49,10 @@ public function after(IRequest $request, IResponse $response): void /** * Record a failure when an exception occurs. + * @param IRequest $request + * @throws InvalidArgumentException */ - public function onException(IRequest $request, \Throwable $exception): void + public function onException(IRequest $request): void { $service = $this->resolveServiceName($request); $this->circuitBreaker->failure($service); @@ -55,6 +60,8 @@ public function onException(IRequest $request, \Throwable $exception): void /** * Resolve the service name from the request. + * @param IRequest $request + * @return string */ protected function resolveServiceName(IRequest $request): string { diff --git a/src/CircuitBreaker/Config/CircuitBreakerConfig.php b/src/CircuitBreaker/Config/CircuitBreakerConfig.php index 2f7bc04..32d48b9 100644 --- a/src/CircuitBreaker/Config/CircuitBreakerConfig.php +++ b/src/CircuitBreaker/Config/CircuitBreakerConfig.php @@ -2,12 +2,6 @@ namespace Idaratech\Integrations\CircuitBreaker\Config; -/** - * Abstract base class for circuit breaker configuration. - * - * Provides shared configuration properties and methods that are common - * to all circuit breaker strategies. - */ abstract class CircuitBreakerConfig { protected int $timeWindow = 60; @@ -21,13 +15,16 @@ abstract class CircuitBreakerConfig protected array $ignoredStatusCodes = []; /** - * Set the time window in seconds to track requests/failures. - * - * @param int $seconds Time window in seconds + * @param int $seconds * @return static */ public function timeWindow(int $seconds): static { + if ($seconds < 1) { + throw new \InvalidArgumentException( + "Time window must be at least 1 seconds, got: {$seconds}" + ); + } $this->timeWindow = $seconds; return $this; } @@ -35,11 +32,16 @@ public function timeWindow(int $seconds): static /** * Set the interval in seconds before attempting recovery (OPEN → HALF_OPEN). * - * @param int $seconds Interval in seconds + * @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; } @@ -47,11 +49,16 @@ public function intervalToHalfOpen(int $seconds): static /** * Set the number of consecutive successes needed to close the circuit. * - * @param int $count Number of successes + * @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; } @@ -59,7 +66,7 @@ public function successThreshold(int $count): static /** * Set the storage adapter type ('redis' or 'cache'). * - * @param string $type Storage type + * @param string $type * @return static */ public function storage(string $type): static @@ -71,7 +78,7 @@ public function storage(string $type): static /** * Set the cache key prefix for circuit breaker state. * - * @param string $prefix Cache key prefix + * @param string $prefix * @return static */ public function prefix(string $prefix): static @@ -83,7 +90,7 @@ public function prefix(string $prefix): static /** * Set the Redis connection name (only for Redis storage). * - * @param string|null $connection Redis connection name + * @param string|null $connection * @return static */ public function redisConnection(?string $connection): static @@ -95,7 +102,7 @@ public function redisConnection(?string $connection): static /** * Set the cache store name (only for cache storage). * - * @param string|null $store Cache store name + * @param string|null $store * @return static */ public function cacheStore(?string $store): static @@ -107,7 +114,7 @@ public function cacheStore(?string $store): static /** * Set the HTTP status codes that are considered failures. * - * @param array $codes Array of HTTP status codes + * @param array $codes * @return static */ public function failureStatusCodes(array $codes): static @@ -119,7 +126,7 @@ public function failureStatusCodes(array $codes): static /** * Set the HTTP status codes that should be ignored by the circuit breaker. * - * @param array $codes Array of HTTP status codes + * @param array $codes * @return static */ public function ignoredStatusCodes(array $codes): static @@ -128,8 +135,6 @@ public function ignoredStatusCodes(array $codes): static return $this; } - // Getters - public function getTimeWindow(): int { return $this->timeWindow; @@ -150,9 +155,9 @@ public function getStorage(): string return $this->storage; } - public function getPrefix(): ?string + public function getPrefix(): string { - return $this->prefix; + return $this->prefix ?? config('integrations.circuit_breaker_prefix', 'cb:app'); } public function getRedisConnection(): ?string diff --git a/src/CircuitBreaker/Config/CountStrategyConfig.php b/src/CircuitBreaker/Config/CountStrategyConfig.php index bfd96f9..9dc293d 100644 --- a/src/CircuitBreaker/Config/CountStrategyConfig.php +++ b/src/CircuitBreaker/Config/CountStrategyConfig.php @@ -2,15 +2,9 @@ namespace Idaratech\Integrations\CircuitBreaker\Config; -/** - * Configuration for count-based circuit breaker strategy. - * - * Trips the circuit when the absolute failure count exceeds a threshold - * within a specified time window. - */ class CountStrategyConfig extends CircuitBreakerConfig { - protected int $failureCountThreshold = 5; + protected ?int $failureCountThreshold = null; /** * Create a new count strategy configuration instance. @@ -26,20 +20,36 @@ public static function make(): self * Set the failure count threshold. * Circuit trips when this many failures occur within the time window. * - * @param int $count Number of failures to trip circuit + * @param int $count * @return self */ public function failureCountThreshold(int $count): self { + if ($count < 1) { + throw new \InvalidArgumentException( + "Failure count threshold must be at least 1, got: {$count}" + ); + } $this->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 index b43c41f..ca92b88 100644 --- a/src/CircuitBreaker/Config/RateStrategyConfig.php +++ b/src/CircuitBreaker/Config/RateStrategyConfig.php @@ -2,16 +2,10 @@ namespace Idaratech\Integrations\CircuitBreaker\Config; -/** - * Configuration for rate-based circuit breaker strategy. - * - * Trips the circuit when the failure rate (percentage) exceeds a threshold - * within a specified time window. - */ class RateStrategyConfig extends CircuitBreakerConfig { - protected float $failureRateThreshold = 50.0; - protected int $minimumRequests = 10; + protected ?float $failureRateThreshold = null; + protected ?int $minimumRequests = null; /** * Create a new rate strategy configuration instance. @@ -27,11 +21,16 @@ public static function make(): self * Set the failure rate threshold percentage (0-100). * Circuit trips when failure rate exceeds this percentage. * - * @param float $percentage Failure rate percentage + * @param float $percentage * @return self */ public function failureRateThreshold(float $percentage): self { + if ($percentage < 0 || $percentage > 100) { + throw new \InvalidArgumentException( + "Failure rate threshold must be between 0 and 100, got: {$percentage}" + ); + } $this->failureRateThreshold = $percentage; return $this; } @@ -40,22 +39,37 @@ public function failureRateThreshold(float $percentage): self * Set the minimum number of requests before evaluating failure rate. * Circuit won't trip until this many requests have been made. * - * @param int $count Minimum requests + * @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; } diff --git a/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php b/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php index 3c7eabc..c1b6cf8 100644 --- a/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php +++ b/src/CircuitBreaker/Contracts/CircuitBreakerStorage.php @@ -3,71 +3,92 @@ namespace Idaratech\Integrations\CircuitBreaker\Contracts; use Idaratech\Integrations\CircuitBreaker\Enums\CircuitState; +use Psr\SimpleCache\InvalidArgumentException; interface CircuitBreakerStorage { /** * Get the current state of the circuit. + * @param string $service + * @return CircuitState + * @throws InvalidArgumentException */ public function getState(string $service): CircuitState; /** * Set the state of the circuit. + * @param string $service + * @param CircuitState $state + * @param int|null $ttl */ public function setState(string $service, CircuitState $state, ?int $ttl = null): void; /** * Increment the failure count for a service. + * @param string $service + * @param int $timeWindow */ public function incrementFailure(string $service, int $timeWindow): int; /** * Increment the success count for a service. + * @param string $service + * @param int $timeWindow */ public function incrementSuccess(string $service, int $timeWindow): int; /** * Get the failure count for a service. + * @param string $service */ public function getFailureCount(string $service): int; /** * Get the success count for a service. + * @param string $service */ public function getSuccessCount(string $service): int; /** * Get the total request count (failures + successes). + * @param string $service */ public function getRequestCount(string $service): int; /** * Reset all circuit breaker state for a service. + * @param string $service */ public function reset(string $service): void; /** * Get the timestamp when the circuit was opened. + * @param string $service */ public function getOpenedAt(string $service): ?int; /** * Set the timestamp when the circuit was opened. + * @param string $service + * @param int $timestamp */ public function setOpenedAt(string $service, int $timestamp): void; /** * Get the success count in half-open state. + * @param string $service */ public function getHalfOpenSuccessCount(string $service): int; /** * Increment the success count in half-open state. + * @param string $service */ public function incrementHalfOpenSuccess(string $service): int; /** * Reset the success count in half-open state. + * @param string $service */ public function resetHalfOpenSuccess(string $service): void; } diff --git a/src/CircuitBreaker/Contracts/StrategyInterface.php b/src/CircuitBreaker/Contracts/StrategyInterface.php index c8f67be..0f18793 100644 --- a/src/CircuitBreaker/Contracts/StrategyInterface.php +++ b/src/CircuitBreaker/Contracts/StrategyInterface.php @@ -6,46 +6,74 @@ interface StrategyInterface { /** * Check if the circuit should trip to open state. + * + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool */ public function shouldTrip(string $service, CircuitBreakerStorage $storage): bool; /** * Check if the circuit should transition from half-open to closed. + * + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool */ public function shouldClose(string $service, CircuitBreakerStorage $storage): bool; /** * Check if enough time has passed to transition from open to half-open. + * + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool */ public function shouldAttemptReset(string $service, CircuitBreakerStorage $storage): bool; /** * Record a successful operation. + * + * @param string $service + * @param CircuitBreakerStorage $storage + * @return void */ public function recordSuccess(string $service, CircuitBreakerStorage $storage): void; /** * Record a failed operation. + * + * @param string $service + * @param CircuitBreakerStorage $storage + * @return void */ public function recordFailure(string $service, CircuitBreakerStorage $storage): void; /** * Get the time window in seconds. + * + * @return int */ public function getTimeWindow(): int; /** * Get the interval to half-open in seconds. + * + * @return int */ public function getIntervalToHalfOpen(): int; /** * Get the minimum requests before evaluation. + * + * @return int */ public function getMinimumRequests(): int; /** * Get the success threshold for half-open state. + * + * @return int */ public function getSuccessThreshold(): int; -} +} \ No newline at end of file diff --git a/src/CircuitBreaker/Exceptions/CircuitOpenException.php b/src/CircuitBreaker/Exceptions/CircuitOpenException.php index bfe5621..bc28fea 100644 --- a/src/CircuitBreaker/Exceptions/CircuitOpenException.php +++ b/src/CircuitBreaker/Exceptions/CircuitOpenException.php @@ -10,6 +10,11 @@ class CircuitOpenException extends Exception protected string $service; protected CircuitState $state; + /** + * @param string $service + * @param CircuitState $state + * @param Exception|null $previous + */ public function __construct( string $service, CircuitState $state = CircuitState::OPEN, diff --git a/src/CircuitBreaker/Storage/CacheStorage.php b/src/CircuitBreaker/Storage/CacheStorage.php index 357b91f..58508c8 100644 --- a/src/CircuitBreaker/Storage/CacheStorage.php +++ b/src/CircuitBreaker/Storage/CacheStorage.php @@ -3,7 +3,6 @@ namespace Idaratech\Integrations\CircuitBreaker\Storage; use Illuminate\Contracts\Cache\Repository; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Idaratech\Integrations\CircuitBreaker\Contracts\CircuitBreakerStorage; use Idaratech\Integrations\CircuitBreaker\Enums\CircuitState; @@ -14,18 +13,29 @@ class CacheStorage implements CircuitBreakerStorage protected Repository $cache; protected string $prefix; + /** + * @param string $prefix + * @param string|null $store + */ public function __construct(string $prefix = 'circuit_breaker', ?string $store = null) { $this->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 @@ -35,6 +45,12 @@ public function getState(string $service): CircuitState 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'); @@ -46,25 +62,41 @@ public function setState(string $service, CircuitState $state, ?int $ttl = null) } } + /** + * @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 { - // Add key with TTL if it doesn't exist, then increment $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 @@ -73,6 +105,8 @@ public function getFailureCount(string $service): int } /** + * @param string $service + * @return int * @throws InvalidArgumentException */ public function getSuccessCount(string $service): int @@ -81,6 +115,8 @@ public function getSuccessCount(string $service): int } /** + * @param string $service + * @return int * @throws InvalidArgumentException */ public function getRequestCount(string $service): int @@ -88,6 +124,10 @@ 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']; @@ -98,6 +138,8 @@ public function reset(string $service): void } /** + * @param string $service + * @return int|null * @throws InvalidArgumentException */ public function getOpenedAt(string $service): ?int @@ -107,12 +149,20 @@ public function getOpenedAt(string $service): ?int 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 @@ -120,15 +170,19 @@ 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 { - $key = $this->key($service, 'half_open_successes'); - - $this->cache->add($key, 0, Carbon::now()->addDay()); - - return (int)$this->cache->increment($key); + 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 index 8102421..52f3f84 100644 --- a/src/CircuitBreaker/Storage/RedisStorage.php +++ b/src/CircuitBreaker/Storage/RedisStorage.php @@ -12,17 +12,30 @@ class RedisStorage implements CircuitBreakerStorage protected Connection $redis; protected string $prefix; + /** + * @param string $prefix + * @param string|null $connection + */ public function __construct(string $prefix = 'circuit_breaker', ?string $connection = null) { $this->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')); @@ -30,6 +43,12 @@ public function getState(string $service): CircuitState 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'); @@ -41,39 +60,81 @@ public function setState(string $service, CircuitState $state, ?int $ttl = null) } } + /** + * 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 { - $count = $this->redis->incr($key); - $this->redis->expire($key, $ttl); + $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) $count; + 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([ @@ -85,6 +146,10 @@ public function reset(string $service): void ]); } + /** + * @param string $service + * @return int|null + */ public function getOpenedAt(string $service): ?int { $value = $this->redis->get($this->key($service, 'opened_at')); @@ -92,21 +157,39 @@ public function getOpenedAt(string $service): ?int 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 index 52e1128..25246f7 100644 --- a/src/CircuitBreaker/Strategy/CountStrategy.php +++ b/src/CircuitBreaker/Strategy/CountStrategy.php @@ -5,14 +5,14 @@ use Idaratech\Integrations\CircuitBreaker\Contracts\CircuitBreakerStorage; use Idaratech\Integrations\CircuitBreaker\Contracts\StrategyInterface; -/** - * Count-based circuit breaker strategy. - * - * Trips the circuit when the absolute failure count exceeds a threshold - * within a specified time window. - */ class CountStrategy implements StrategyInterface { + /** + * @param int $timeWindow + * @param int $failureCountThreshold + * @param int $intervalToHalfOpen + * @param int $successThreshold + */ public function __construct( protected readonly int $timeWindow = 60, protected readonly int $failureCountThreshold = 5, @@ -20,6 +20,11 @@ public function __construct( protected readonly int $successThreshold = 3 ) {} + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ public function shouldTrip(string $service, CircuitBreakerStorage $storage): bool { $failures = $storage->getFailureCount($service); @@ -27,6 +32,11 @@ public function shouldTrip(string $service, CircuitBreakerStorage $storage): boo return $failures >= $this->failureCountThreshold; } + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ public function shouldClose(string $service, CircuitBreakerStorage $storage): bool { $halfOpenSuccesses = $storage->getHalfOpenSuccessCount($service); @@ -34,6 +44,11 @@ public function shouldClose(string $service, CircuitBreakerStorage $storage): bo return $halfOpenSuccesses >= $this->successThreshold; } + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ public function shouldAttemptReset(string $service, CircuitBreakerStorage $storage): bool { $openedAt = $storage->getOpenedAt($service); @@ -45,38 +60,63 @@ public function shouldAttemptReset(string $service, CircuitBreakerStorage $stora 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 index 52f1047..f946282 100644 --- a/src/CircuitBreaker/Strategy/RateStrategy.php +++ b/src/CircuitBreaker/Strategy/RateStrategy.php @@ -5,14 +5,15 @@ use Idaratech\Integrations\CircuitBreaker\Contracts\CircuitBreakerStorage; use Idaratech\Integrations\CircuitBreaker\Contracts\StrategyInterface; -/** - * Rate-based circuit breaker strategy. - * - * Trips the circuit when the failure rate exceeds a threshold percentage - * within a specified time window. - */ class RateStrategy implements StrategyInterface { + /** + * @param int $timeWindow + * @param float $failureRateThreshold + * @param int $minimumRequests + * @param int $intervalToHalfOpen + * @param int $successThreshold + */ public function __construct( protected readonly int $timeWindow = 60, protected readonly float $failureRateThreshold = 50.0, @@ -21,6 +22,11 @@ public function __construct( protected readonly int $successThreshold = 3 ) {} + /** + * @param string $service + * @param CircuitBreakerStorage $storage + * @return bool + */ public function shouldTrip(string $service, CircuitBreakerStorage $storage): bool { $totalRequests = $storage->getRequestCount($service); @@ -34,11 +40,21 @@ public function shouldTrip(string $service, CircuitBreakerStorage $storage): boo 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); @@ -46,36 +62,61 @@ public function shouldAttemptReset(string $service, CircuitBreakerStorage $stora 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; diff --git a/src/Client.php b/src/Client.php index 75a14f1..dea1d29 100644 --- a/src/Client.php +++ b/src/Client.php @@ -37,7 +37,6 @@ class Client implements ClientInterface protected ResponseFactory $responseFactory; protected ResponseMapperInterface $mapper; protected ?CircuitBreakerInterceptor $circuitBreakerInterceptor = null; - protected bool $circuitBreakerEnabled = false; public function __construct(?string $baseUri = null, array $headers = [], array $options = []) { @@ -165,7 +164,7 @@ public function do(RequestInterface $request): ResponseInterface return $response; } catch (Throwable $e) { - $this->circuitBreakerInterceptor?->onException($request, $e); + $this->circuitBreakerInterceptor?->onException($request); throw $e; } } @@ -242,25 +241,12 @@ protected function configureCircuitBreakerFromConfig(): void { $config = $this->circuitBreakerConfig(); - // Priority 1: Custom config (highest priority) + // Only custom config supported - no global fallback if ($config !== null) { $factory = app(CircuitBreakerFactory::class); $circuitBreaker = $factory->createFromConfig($config); $this->withCircuitBreaker($circuitBreaker); - return; } - - // Priority 2: Per-service enabled flag - if ($this->circuitBreakerEnabled) { - try { - $globalCircuitBreaker = app(CircuitBreaker::class); - $this->withCircuitBreaker($globalCircuitBreaker); - } catch (\Throwable $e) { - // Silently ignore if circuit breaker dependencies are not available - } - } - - // No global config check - circuit breaker is always per-service controlled } public function withCircuitBreaker(CircuitBreaker $circuitBreaker): ClientInterface diff --git a/src/IntegrationsServiceProvider.php b/src/IntegrationsServiceProvider.php index d1ae2f2..f91aa3e 100644 --- a/src/IntegrationsServiceProvider.php +++ b/src/IntegrationsServiceProvider.php @@ -1,97 +1,43 @@ -mergeConfigFrom(__DIR__.'/../config/integrations.php', 'integrations'); - - $this->app->bind(Transport::class, function ($app) { - $headers = config('integrations.default_headers', []); - $timeout = config('integrations.timeout', null); - $retry = config('integrations.retry.times', 0); - $sleep = config('integrations.retry.sleep_ms', 0); - - return (new LaravelHttpTransport($headers, $timeout, $retry, $sleep)); - }); - - $this->app->bind(ResponseMapperInterface::class, DefaultResponseMapper::class); - - $this->registerCircuitBreaker(); - } - - protected function registerCircuitBreaker(): void - { - // Register circuit breaker factory for per-service configuration - $this->app->singleton(CircuitBreakerFactory::class, function () { - return new CircuitBreakerFactory(); - }); - - // Register global circuit breaker singleton as fallback - // Used only when circuitBreakerConfig() is not overridden and enabled in config - $this->app->singleton(CircuitBreaker::class, function ($app) { - $config = config('integrations.circuit_breaker'); - - // Create storage adapter - $storage = $this->createStorageFromConfig($config); - - // Create strategy - $strategy = match ($config['strategy']) { - 'count' => new CountStrategy( - $config['time_window'], - $config['failure_count_threshold'], - $config['interval_to_half_open'], - $config['success_threshold'] - ), - default => new RateStrategy( - $config['time_window'], - $config['failure_rate_threshold'], - $config['minimum_requests'], - $config['interval_to_half_open'], - $config['success_threshold'] - ), - }; - - return (new CircuitBreaker($storage, $strategy)) - ->setFailureStatusCodes($config['failure_status_codes'] ?? []) - ->setIgnoredStatusCodes($config['ignored_status_codes'] ?? []); - }); - } - - protected function createStorageFromConfig(array $config): CircuitBreakerStorage - { - if ($config['storage'] === 'redis') { - try { - return new RedisStorage($config['prefix'], $config['redis_connection'] ?? null); - } catch (\Throwable $e) { - Log::warning('Circuit breaker: Redis unavailable, falling back to cache', [ - 'error' => $e->getMessage(), - ]); - } - } - return new CacheStorage($config['prefix'], $config['cache_store'] ?? null); - } - - public function boot(): void - { - $this->publishes([ - __DIR__.'/../config/integrations.php' => config_path('integrations.php'), - ], 'config'); - } -} +mergeConfigFrom(__DIR__.'/../config/integrations.php', 'integrations'); + + $this->app->bind(Transport::class, function ($app) { + $headers = config('integrations.default_headers', []); + $timeout = config('integrations.timeout', null); + $retry = config('integrations.retry.times', 0); + $sleep = config('integrations.retry.sleep_ms', 0); + + return (new LaravelHttpTransport($headers, $timeout, $retry, $sleep)); + }); + + $this->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 + { + $this->publishes([ + __DIR__.'/../config/integrations.php' => config_path('integrations.php'), + ], 'config'); + } +} From f00c7220f65b6d68a06b5fdc800aa25cfb2985f5 Mon Sep 17 00:00:00 2001 From: Bilal-Elhalawaty Date: Mon, 29 Dec 2025 18:49:55 +0200 Subject: [PATCH 3/3] feat: add Circuit Breaker implementation overview and file structure documentation --- docs/CIRCUIT_BREAKER.md | 231 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/CIRCUIT_BREAKER.md 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