"""Unit tests for the passive circuit breaker (circuit_breaker.py). Offline, no network. Drive the pure get/record/clear logic directly. time.time is monkeypatched so cooldown expiry is deterministic (no wall-clock dependence). """ from __future__ import annotations import pytest import circuit_breaker from verification import verify_response @pytest.fixture(autouse=True) def _clean_circuit(): circuit_breaker.clear() yield circuit_breaker.clear() def _freeze(monkeypatch, t: float): monkeypatch.setattr(circuit_breaker.time, "time", lambda: t) def test_is_down_miss_returns_false(): assert circuit_breaker.is_down("m1", "p1", now=1000.0) is False def test_first_failure_sets_initial_cooldown(monkeypatch): _freeze(monkeypatch, 1000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) # Within the cooldown window it's down. assert circuit_breaker.is_down("m1", "p1", now=1000.0) is True # Just past the window it is not down (passive recovery probe). assert circuit_breaker.is_down("m1", "p1", now=1000.0 + 30.0) is False def test_second_consecutive_failure_doubles_cooldown(monkeypatch): _freeze(monkeypatch, 1000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) _freeze(monkeypatch, 1000.0 + 100.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) # Second failure at t=1100: cooldown doubled to 60, down_until = 1160. assert circuit_breaker.is_down("m1", "p1", now=1159.0) is True assert circuit_breaker.is_down("m1", "p1", now=1160.0) is False def test_cooldown_never_exceeds_max(monkeypatch): _freeze(monkeypatch, 1000.0) circuit_breaker.record_failure("m1", "p1", 10.0, 25.0, 2.0) # -> 10 _freeze(monkeypatch, 2000.0) circuit_breaker.record_failure("m1", "p1", 10.0, 25.0, 2.0) # -> 20 _freeze(monkeypatch, 3000.0) circuit_breaker.record_failure("m1", "p1", 10.0, 25.0, 2.0) # -> capped 25 _freeze(monkeypatch, 4000.0) circuit_breaker.record_failure("m1", "p1", 10.0, 25.0, 2.0) # -> capped 25 assert circuit_breaker.is_down("m1", "p1", now=4024.0) is True assert circuit_breaker.is_down("m1", "p1", now=4025.0) is False def test_success_clears_and_next_failure_restarts_at_initial(monkeypatch): _freeze(monkeypatch, 1000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) _freeze(monkeypatch, 2000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) # -> 60 circuit_breaker.record_success("m1", "p1") assert circuit_breaker.is_down("m1", "p1", now=2000.0) is False _freeze(monkeypatch, 3000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) # Restarts at the INITIAL cooldown, not wherever backoff had climbed. assert circuit_breaker.is_down("m1", "p1", now=3029.0) is True assert circuit_breaker.is_down("m1", "p1", now=3030.0) is False def test_state_is_keyed_by_model_and_provider(monkeypatch): _freeze(monkeypatch, 1000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) # Same model, different provider is unaffected. assert circuit_breaker.is_down("m1", "p2", now=1000.0) is False assert circuit_breaker.is_down("m1", "p1", now=1000.0) is True def test_malformed_verdict_trips_breaker(monkeypatch): result = verify_response("", None, has_tool_calls=False) assert result.verdict == "malformed" _freeze(monkeypatch, 1000.0) circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) assert circuit_breaker.is_down("m1", "p1", now=1000.0) is True def test_tool_call_empty_content_does_not_trip_breaker(): result = verify_response("", None, has_tool_calls=True) assert result.verdict == "unverifiable" assert result.verdict != "malformed" def test_ok_verdict_does_not_trip_breaker(): result = verify_response( "```python\nprint('hello')\n```", None, has_tool_calls=False ) assert result.verdict == "ok" assert result.verdict != "malformed" def test_truncated_verdict_does_not_trip_breaker(): result = verify_response("some text", "length", has_tool_calls=False) assert result.verdict == "truncated" assert result.verdict != "malformed" def test_stream_cancelled_does_not_trip_breaker(): """A client disconnect (GeneratorExit) must NOT record_failure. The streaming generator's finally block runs on disconnect. An interrupted stream verifies as 'malformed' (empty content), but that is a pipeline event, not a model fault. The stream_completed flag gates the breaker call. """ # Simulate: verify_response on empty content returns malformed result = verify_response("", None, has_tool_calls=False) assert result.verdict == "malformed" # But stream_completed=False means the breaker should NOT be touched stream_completed = False if stream_completed: circuit_breaker.record_failure("m1", "p1", 30.0, 600.0, 2.0) assert circuit_breaker.is_down("m1", "p1", now=0.0) is False