"""Tests for CircuitBreaker class.""" import json import pytest from meshmon.retry import CircuitBreaker BASE_TS = 1704067200 @pytest.fixture def time_controller(monkeypatch): """Control time.time() within meshmon.retry.""" state = {"now": BASE_TS} def _time(): return state["now"] monkeypatch.setattr("meshmon.retry.time.time", _time) return state class TestCircuitBreakerInit: """Tests for CircuitBreaker initialization.""" def test_creates_with_fresh_state(self, circuit_state_file): """Fresh circuit breaker has zero failures and no cooldown.""" cb = CircuitBreaker(circuit_state_file) assert cb.consecutive_failures == 0 assert cb.cooldown_until == 0 assert cb.last_success == 0 def test_loads_existing_state(self, closed_circuit): """Loads state from existing file.""" cb = CircuitBreaker(closed_circuit) assert cb.consecutive_failures == 0 assert cb.cooldown_until == 0 assert cb.last_success > 0 def test_loads_open_circuit_state(self, open_circuit, time_controller): """Loads open circuit state correctly.""" cb = CircuitBreaker(open_circuit) assert cb.consecutive_failures == 10 assert cb.cooldown_until == BASE_TS + 3600 assert cb.is_open() is True def test_handles_corrupted_file(self, corrupted_state_file): """Corrupted JSON file loads defaults without crashing.""" cb = CircuitBreaker(corrupted_state_file) # Should use defaults assert cb.consecutive_failures == 0 assert cb.cooldown_until == 0 assert cb.last_success == 0 def test_handles_partial_state(self, partial_state_file): """Missing keys in state file use defaults.""" cb = CircuitBreaker(partial_state_file) assert cb.consecutive_failures == 5 # Present in file assert cb.cooldown_until == 0 # Default assert cb.last_success == 0 # Default def test_handles_nonexistent_file(self, circuit_state_file): """Nonexistent state file uses defaults.""" assert not circuit_state_file.exists() cb = CircuitBreaker(circuit_state_file) assert cb.consecutive_failures == 0 def test_stores_state_file_path(self, circuit_state_file): """Stores the state file path.""" cb = CircuitBreaker(circuit_state_file) assert cb.state_file == circuit_state_file class TestCircuitBreakerIsOpen: """Tests for is_open method.""" def test_closed_circuit_returns_false(self, closed_circuit): """Closed circuit (no cooldown) returns False.""" cb = CircuitBreaker(closed_circuit) assert cb.is_open() is False def test_open_circuit_returns_true(self, open_circuit, time_controller): """Open circuit (in cooldown) returns True.""" cb = CircuitBreaker(open_circuit) assert cb.is_open() is True def test_expired_cooldown_returns_false(self, expired_cooldown_circuit, time_controller): """Expired cooldown returns False (circuit closes).""" cb = CircuitBreaker(expired_cooldown_circuit) assert cb.is_open() is False def test_cooldown_expiry(self, circuit_state_file, time_controller): """Circuit closes when cooldown expires.""" # Set cooldown to 10 seconds from now state = { "consecutive_failures": 10, "cooldown_until": BASE_TS + 10, "last_success": 0, } circuit_state_file.write_text(json.dumps(state)) cb = CircuitBreaker(circuit_state_file) assert cb.is_open() is True time_controller["now"] = BASE_TS + 11 assert cb.is_open() is False class TestCooldownRemaining: """Tests for cooldown_remaining method.""" def test_returns_zero_when_closed(self, closed_circuit): """Returns 0 when circuit is closed.""" cb = CircuitBreaker(closed_circuit) assert cb.cooldown_remaining() == 0 def test_returns_seconds_when_open(self, circuit_state_file, time_controller): """Returns remaining seconds when in cooldown.""" state = { "consecutive_failures": 10, "cooldown_until": BASE_TS + 100, "last_success": 0, } circuit_state_file.write_text(json.dumps(state)) cb = CircuitBreaker(circuit_state_file) remaining = cb.cooldown_remaining() assert remaining == 100 def test_returns_zero_when_expired(self, expired_cooldown_circuit): """Returns 0 when cooldown has expired.""" cb = CircuitBreaker(expired_cooldown_circuit) assert cb.cooldown_remaining() == 0 def test_returns_integer(self, open_circuit, time_controller): """Returns an integer, not float.""" cb = CircuitBreaker(open_circuit) assert isinstance(cb.cooldown_remaining(), int) class TestRecordSuccess: """Tests for record_success method.""" def test_resets_failure_count(self, circuit_state_file): """Success resets consecutive failure count to 0.""" state = { "consecutive_failures": 5, "cooldown_until": 0, "last_success": 0, } circuit_state_file.write_text(json.dumps(state)) cb = CircuitBreaker(circuit_state_file) cb.record_success() assert cb.consecutive_failures == 0 def test_updates_last_success(self, circuit_state_file, time_controller): """Success updates last_success timestamp.""" cb = CircuitBreaker(circuit_state_file) time_controller["now"] = BASE_TS + 5 cb.record_success() assert cb.last_success == BASE_TS + 5 def test_persists_to_file(self, circuit_state_file): """Success state is persisted to file.""" cb = CircuitBreaker(circuit_state_file) cb.consecutive_failures = 5 cb.record_success() # Read file directly data = json.loads(circuit_state_file.read_text()) assert data["consecutive_failures"] == 0 assert data["last_success"] > 0 def test_creates_parent_dirs(self, tmp_path): """Creates parent directories if they don't exist.""" nested_path = tmp_path / "deep" / "nested" / "circuit.json" cb = CircuitBreaker(nested_path) cb.record_success() assert nested_path.exists() class TestRecordFailure: """Tests for record_failure method.""" def test_increments_failure_count(self, circuit_state_file): """Failure increments consecutive failure count.""" cb = CircuitBreaker(circuit_state_file) cb.record_failure(max_failures=10, cooldown_s=3600) assert cb.consecutive_failures == 1 def test_opens_circuit_at_threshold(self, circuit_state_file, time_controller): """Circuit opens when failures reach threshold.""" cb = CircuitBreaker(circuit_state_file) # Record failures up to threshold for _ in range(5): cb.record_failure(max_failures=5, cooldown_s=3600) assert cb.is_open() is True assert cb.cooldown_until == BASE_TS + 3600 def test_does_not_open_before_threshold(self, circuit_state_file, time_controller): """Circuit stays closed before reaching threshold.""" cb = CircuitBreaker(circuit_state_file) for _ in range(4): cb.record_failure(max_failures=5, cooldown_s=3600) assert cb.is_open() is False def test_cooldown_duration(self, circuit_state_file, time_controller): """Cooldown is set to specified duration.""" cb = CircuitBreaker(circuit_state_file) for _ in range(5): cb.record_failure(max_failures=5, cooldown_s=100) # Cooldown should be ~100 seconds from now assert cb.cooldown_until == BASE_TS + 100 def test_persists_to_file(self, circuit_state_file): """Failure state is persisted to file.""" cb = CircuitBreaker(circuit_state_file) cb.record_failure(max_failures=10, cooldown_s=3600) data = json.loads(circuit_state_file.read_text()) assert data["consecutive_failures"] == 1 class TestToDict: """Tests for to_dict method.""" def test_includes_all_fields(self, closed_circuit): """Dict includes all state fields.""" cb = CircuitBreaker(closed_circuit) d = cb.to_dict() assert "consecutive_failures" in d assert "cooldown_until" in d assert "last_success" in d assert "is_open" in d assert "cooldown_remaining_s" in d def test_is_open_reflects_state(self, open_circuit, time_controller): """is_open in dict reflects actual circuit state.""" cb = CircuitBreaker(open_circuit) d = cb.to_dict() assert d["is_open"] is True def test_cooldown_remaining_reflects_state(self, open_circuit, time_controller): """cooldown_remaining_s reflects actual remaining time.""" cb = CircuitBreaker(open_circuit) d = cb.to_dict() assert d["cooldown_remaining_s"] > 0 def test_closed_circuit_dict(self, closed_circuit): """Closed circuit has expected dict values.""" cb = CircuitBreaker(closed_circuit) d = cb.to_dict() assert d["consecutive_failures"] == 0 assert d["is_open"] is False assert d["cooldown_remaining_s"] == 0 class TestStatePersistence: """Tests for state persistence across instances.""" def test_state_survives_reload(self, circuit_state_file): """State persists across CircuitBreaker instances.""" cb1 = CircuitBreaker(circuit_state_file) cb1.record_failure(max_failures=10, cooldown_s=3600) cb1.record_failure(max_failures=10, cooldown_s=3600) cb1.record_failure(max_failures=10, cooldown_s=3600) # Create new instance cb2 = CircuitBreaker(circuit_state_file) assert cb2.consecutive_failures == 3 def test_success_resets_across_reload(self, circuit_state_file): """Success reset persists across instances.""" cb1 = CircuitBreaker(circuit_state_file) for _ in range(5): cb1.record_failure(max_failures=10, cooldown_s=3600) cb1.record_success() cb2 = CircuitBreaker(circuit_state_file) assert cb2.consecutive_failures == 0 def test_open_state_survives_reload(self, circuit_state_file, time_controller): """Open circuit state persists across instances.""" cb1 = CircuitBreaker(circuit_state_file) for _ in range(10): cb1.record_failure(max_failures=10, cooldown_s=3600) assert cb1.is_open() is True cb2 = CircuitBreaker(circuit_state_file) assert cb2.is_open() is True assert cb2.consecutive_failures == 10