forked from core/php-mcp
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) <noreply@anthropic.com>
This commit is contained in:
parent
8c2a532899
commit
3e14b367c3
1 changed files with 556 additions and 0 deletions
556
src/Mcp/Tests/Unit/CircuitBreakerTest.php
Normal file
556
src/Mcp/Tests/Unit/CircuitBreakerTest.php
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mcp\Tests\Unit;
|
||||
|
||||
use Core\Mcp\Exceptions\CircuitOpenException;
|
||||
use Core\Mcp\Services\CircuitBreaker;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CircuitBreakerTest extends TestCase
|
||||
{
|
||||
protected CircuitBreaker $breaker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue