From fa867cfa7dc3360d3bbcdde6b47b2b1f2645b935 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 16:10:58 +0000 Subject: [PATCH] test: add comprehensive tests for CircuitBreaker service Cover all three circuit states (closed, open, half-open), failure threshold tripping, reset timeout transitions, manual reset, probe recovery, trial lock contention, success decay, service isolation, recoverable error detection, and statistics tracking. Fixes #6 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Mcp/Tests/Unit/CircuitBreakerTest.php | 556 ++++++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 src/Mcp/Tests/Unit/CircuitBreakerTest.php diff --git a/src/Mcp/Tests/Unit/CircuitBreakerTest.php b/src/Mcp/Tests/Unit/CircuitBreakerTest.php new file mode 100644 index 0000000..72647a5 --- /dev/null +++ b/src/Mcp/Tests/Unit/CircuitBreakerTest.php @@ -0,0 +1,556 @@ +breaker = new CircuitBreaker; + Cache::flush(); + } + + // --------------------------------------------------------------- + // Closed state (default) - requests pass through + // --------------------------------------------------------------- + + public function test_default_state_is_closed(): void + { + $state = $this->breaker->getState('test-service'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $state); + } + + public function test_closed_circuit_passes_operation_through(): void + { + $result = $this->breaker->call('test-service', fn () => 'success'); + + $this->assertSame('success', $result); + } + + public function test_closed_circuit_returns_operation_result(): void + { + $result = $this->breaker->call('test-service', fn () => ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $result); + } + + public function test_closed_circuit_propagates_exceptions(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('operation failed'); + + $this->breaker->call('test-service', function () { + throw new RuntimeException('operation failed'); + }); + } + + public function test_closed_circuit_uses_fallback_on_recoverable_error(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('Connection refused'); + }, + fn () => 'fallback-value', + ); + + $this->assertSame('fallback-value', $result); + } + + public function test_closed_circuit_does_not_use_fallback_on_non_recoverable_error(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('logic error'); + + $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('logic error'); + }, + fn () => 'fallback-value', + ); + } + + public function test_service_is_available_when_closed(): void + { + $this->assertTrue($this->breaker->isAvailable('test-service')); + } + + // --------------------------------------------------------------- + // Failure threshold - trips circuit after N failures + // --------------------------------------------------------------- + + public function test_circuit_trips_after_reaching_failure_threshold(): void + { + // Default threshold is 5 + for ($i = 0; $i < 5; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + } + + public function test_circuit_does_not_trip_below_threshold(): void + { + // Default threshold is 5, so 4 failures should not trip + for ($i = 0; $i < 4; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service')); + } + + public function test_circuit_respects_custom_threshold(): void + { + Config::set('mcp.circuit_breaker.test-service.threshold', 2); + + for ($i = 0; $i < 2; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + } + + public function test_service_is_not_available_when_open(): void + { + $this->tripCircuit('test-service'); + + $this->assertFalse($this->breaker->isAvailable('test-service')); + } + + // --------------------------------------------------------------- + // Open state - fails fast + // --------------------------------------------------------------- + + public function test_open_circuit_throws_circuit_open_exception(): void + { + $this->tripCircuit('test-service'); + + $this->expectException(CircuitOpenException::class); + + $this->breaker->call('test-service', fn () => 'should not run'); + } + + public function test_open_circuit_exception_contains_service_name(): void + { + $this->tripCircuit('my-api'); + + try { + $this->breaker->call('my-api', fn () => 'should not run'); + $this->fail('Expected CircuitOpenException'); + } catch (CircuitOpenException $e) { + $this->assertSame('my-api', $e->service); + $this->assertStringContainsString('my-api', $e->getMessage()); + } + } + + public function test_open_circuit_uses_fallback_when_provided(): void + { + $this->tripCircuit('test-service'); + + $result = $this->breaker->call( + 'test-service', + fn () => 'should not run', + fn () => 'fallback-result', + ); + + $this->assertSame('fallback-result', $result); + } + + public function test_open_circuit_does_not_execute_operation(): void + { + $this->tripCircuit('test-service'); + + $executed = false; + + try { + $this->breaker->call('test-service', function () use (&$executed) { + $executed = true; + }); + } catch (CircuitOpenException) { + // Expected + } + + $this->assertFalse($executed); + } + + // --------------------------------------------------------------- + // Half-open state - probe / recovery + // --------------------------------------------------------------- + + public function test_circuit_transitions_to_half_open_after_reset_timeout(): void + { + $this->tripCircuit('test-service'); + + // Simulate time passing beyond the reset timeout (default 60s) + $this->simulateTimePassing('test-service', 61); + + $state = $this->breaker->getState('test-service'); + + $this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $state); + } + + public function test_circuit_stays_open_before_reset_timeout(): void + { + $this->tripCircuit('test-service'); + + // Simulate time passing but not enough (default reset_timeout is 60s) + $this->simulateTimePassing('test-service', 30); + + $state = $this->breaker->getState('test-service'); + + $this->assertSame(CircuitBreaker::STATE_OPEN, $state); + } + + public function test_half_open_circuit_allows_probe_request(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + $result = $this->breaker->call('test-service', fn () => 'probe-success'); + + $this->assertSame('probe-success', $result); + } + + public function test_half_open_circuit_closes_on_successful_probe(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Successful probe should close the circuit + $this->breaker->call('test-service', fn () => 'ok'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service')); + } + + public function test_half_open_circuit_reopens_on_failed_probe(): void + { + Config::set('mcp.circuit_breaker.test-service.threshold', 1); + + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Failed probe should re-trip the circuit + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('still broken'); + }); + } catch (RuntimeException) { + // Expected + } + + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + } + + public function test_half_open_uses_fallback_when_trial_lock_taken(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Simulate another request holding the trial lock + Cache::put('circuit_breaker:test-service:trial_lock', true, 30); + + $result = $this->breaker->call( + 'test-service', + fn () => 'should not run', + fn () => 'locked-fallback', + ); + + $this->assertSame('locked-fallback', $result); + } + + public function test_half_open_throws_when_trial_lock_taken_and_no_fallback(): void + { + $this->tripCircuit('test-service'); + $this->simulateTimePassing('test-service', 61); + + // Simulate another request holding the trial lock + Cache::put('circuit_breaker:test-service:trial_lock', true, 30); + + $this->expectException(CircuitOpenException::class); + + $this->breaker->call('test-service', fn () => 'should not run'); + } + + public function test_custom_reset_timeout(): void + { + Config::set('mcp.circuit_breaker.test-service.reset_timeout', 120); + + $this->tripCircuit('test-service'); + + // 61 seconds is not enough with custom 120s timeout + $this->simulateTimePassing('test-service', 61); + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('test-service')); + + // 121 seconds should trigger half-open + $this->simulateTimePassing('test-service', 121); + $this->assertSame(CircuitBreaker::STATE_HALF_OPEN, $this->breaker->getState('test-service')); + } + + // --------------------------------------------------------------- + // Manual reset + // --------------------------------------------------------------- + + public function test_manual_reset_closes_circuit(): void + { + $this->tripCircuit('test-service'); + + $this->breaker->reset('test-service'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('test-service')); + } + + public function test_manual_reset_clears_failure_counters(): void + { + $this->tripCircuit('test-service'); + + $this->breaker->reset('test-service'); + + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(0, $stats['failures']); + $this->assertSame(0, $stats['successes']); + $this->assertNull($stats['last_failure']); + $this->assertNull($stats['opened_at']); + } + + public function test_reset_allows_operations_again(): void + { + $this->tripCircuit('test-service'); + + $this->breaker->reset('test-service'); + + $result = $this->breaker->call('test-service', fn () => 'working again'); + + $this->assertSame('working again', $result); + } + + // --------------------------------------------------------------- + // Statistics + // --------------------------------------------------------------- + + public function test_stats_track_failures(): void + { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(1, $stats['failures']); + $this->assertSame('test-service', $stats['service']); + $this->assertNotNull($stats['last_failure']); + $this->assertSame(RuntimeException::class, $stats['last_failure']['class']); + } + + public function test_stats_track_successes(): void + { + $this->breaker->call('test-service', fn () => 'ok'); + + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(1, $stats['successes']); + } + + public function test_stats_report_threshold_and_timeout(): void + { + $stats = $this->breaker->getStats('test-service'); + + $this->assertSame(5, $stats['threshold']); + $this->assertSame(60, $stats['reset_timeout']); + } + + public function test_stats_report_custom_config(): void + { + Config::set('mcp.circuit_breaker.custom-svc.threshold', 10); + Config::set('mcp.circuit_breaker.custom-svc.reset_timeout', 180); + + $stats = $this->breaker->getStats('custom-svc'); + + $this->assertSame(10, $stats['threshold']); + $this->assertSame(180, $stats['reset_timeout']); + } + + // --------------------------------------------------------------- + // Recoverable error detection + // --------------------------------------------------------------- + + public function test_sqlstate_error_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('SQLSTATE[HY000]: General error'); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + public function test_connection_timeout_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('Connection timed out after 30 seconds'); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + public function test_too_many_connections_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException('Too many connections'); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + public function test_table_not_found_is_recoverable(): void + { + $result = $this->breaker->call( + 'test-service', + function () { + throw new RuntimeException("Base table or view not found: 1146 Table 'db.table' doesn't exist"); + }, + fn () => 'recovered', + ); + + $this->assertSame('recovered', $result); + } + + // --------------------------------------------------------------- + // Service isolation + // --------------------------------------------------------------- + + public function test_circuits_are_isolated_per_service(): void + { + $this->tripCircuit('service-a'); + + // service-a should be open + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-a')); + + // service-b should still be closed + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-b')); + } + + public function test_resetting_one_service_does_not_affect_another(): void + { + $this->tripCircuit('service-a'); + $this->tripCircuit('service-b'); + + $this->breaker->reset('service-a'); + + $this->assertSame(CircuitBreaker::STATE_CLOSED, $this->breaker->getState('service-a')); + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState('service-b')); + } + + // --------------------------------------------------------------- + // Success decay + // --------------------------------------------------------------- + + public function test_successful_calls_decay_failure_count(): void + { + // Record 3 failures + for ($i = 0; $i < 3; $i++) { + try { + $this->breaker->call('test-service', function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected + } + } + + $this->assertSame(3, $this->breaker->getStats('test-service')['failures']); + + // A successful call should decrement the failure count + $this->breaker->call('test-service', fn () => 'ok'); + + $this->assertSame(2, $this->breaker->getStats('test-service')['failures']); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Trip the circuit by reaching the failure threshold. + */ + protected function tripCircuit(string $service): void + { + $threshold = (int) config( + "mcp.circuit_breaker.{$service}.threshold", + config('mcp.circuit_breaker.default_threshold', 5), + ); + + for ($i = 0; $i < $threshold; $i++) { + try { + $this->breaker->call($service, function () { + throw new RuntimeException('Connection refused'); + }); + } catch (RuntimeException) { + // Expected - Connection refused is recoverable so fallback would be used + // but without a fallback it re-throws after recording the failure + } + } + + // Verify the circuit actually tripped + $this->assertSame(CircuitBreaker::STATE_OPEN, $this->breaker->getState($service)); + } + + /** + * Simulate time passing by adjusting the opened_at timestamp in cache. + */ + protected function simulateTimePassing(string $service, int $seconds): void + { + Cache::put( + "circuit_breaker:{$service}:opened_at", + time() - $seconds, + 86400, + ); + } +}