"""Step definitions for circuit_breaker_coverage.feature. These steps target specific uncovered lines in circuit_breaker.py: - Lines 160, 162-163, 165-167, 169: sync `call()` — except CircuitBreakerOpen handler during HALF_OPEN state (inner-service cascade prevention). - Lines 191, 193-195: sync `call()` — except BaseException handler during HALF_OPEN state (permit leak prevention for KeyboardInterrupt etc.). - Lines 286, 288-289, 291-293, 295: async `async_call()` — except CircuitBreakerOpen handler during HALF_OPEN (same as sync equivalent). - Line 360: `_on_success()` — stale half-open generation discarded. - Line 384: `_on_failure()` — no-op when circuit is already OPEN. """ import asyncio import contextlib import time from behave import given, then, when from cleveragents.core.circuit_breaker import ( CircuitBreaker, CircuitBreakerOpen, CircuitBreakerState, ) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("the circuit breaker coverage module is imported") def step_circuit_breaker_coverage_module_imported(context): """Verify the module is importable.""" assert CircuitBreaker is not None assert CircuitBreakerOpen is not None assert CircuitBreakerState is not None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _open_breaker(cb): """Drive the breaker into OPEN state by inducing enough failures.""" for _ in range(cb.failure_threshold): with contextlib.suppress(RuntimeError): cb.call(lambda: (_ for _ in ()).throw(RuntimeError("fail"))) assert cb.state == CircuitBreakerState.OPEN def _transition_to_half_open(cb): """Open the breaker, wait for recovery_timeout, then trigger the OPEN -> HALF_OPEN transition via a successful probe call. After one success the breaker is still HALF_OPEN because it needs half_open_max_successes=2 successes to close. """ _open_breaker(cb) # Wait long enough for recovery_timeout + cooldown to elapse. time.sleep(0.05) # The next call transitions the breaker to HALF_OPEN internally # and counts as the first success. result = cb.call(lambda: "probe_ok") assert result == "probe_ok" assert cb.state == CircuitBreakerState.HALF_OPEN # --------------------------------------------------------------------------- # Given: create a circuit breaker with fast recovery # --------------------------------------------------------------------------- @given( "a coverage circuit breaker with failure threshold {threshold:d} and fast recovery" ) def step_create_coverage_fast_recovery_breaker(context, threshold): """Create a breaker that transitions quickly for testing.""" context.cb = CircuitBreaker( failure_threshold=threshold, recovery_timeout=0.01, expected_exception=RuntimeError, half_open_max_successes=2, cooldown_seconds=0.0, name="test-coverage", ) @given("the coverage circuit breaker is transitioned to half-open state") def step_coverage_transition_to_half_open(context): """Transition the breaker CLOSED -> OPEN -> HALF_OPEN.""" _transition_to_half_open(context.cb) # Record the permits before the next call. context.permits_before = context.cb._half_open_permits # --------------------------------------------------------------------------- # When: sync CircuitBreakerOpen from inner service (lines 160-169) # --------------------------------------------------------------------------- @when("I sync-call a function that raises inner CircuitBreakerOpen") def step_sync_call_inner_cbo(context): """Call a function that raises CircuitBreakerOpen inside the breaker.""" def inner_raises_cbo(): raise CircuitBreakerOpen("inner service is open") context.caught_exception = None try: context.cb.call(inner_raises_cbo) except CircuitBreakerOpen as exc: context.caught_exception = exc # --------------------------------------------------------------------------- # When: sync BaseException (KeyboardInterrupt) during HALF_OPEN (lines 191-195) # --------------------------------------------------------------------------- @when("I sync-call a function that raises a KeyboardInterrupt") def step_sync_call_keyboard_interrupt(context): """Call a function that raises KeyboardInterrupt (a BaseException).""" def inner_raises_kbi(): raise KeyboardInterrupt("simulated ctrl-c") context.caught_exception = None try: context.cb.call(inner_raises_kbi) except KeyboardInterrupt as exc: context.caught_exception = exc # --------------------------------------------------------------------------- # When: async CircuitBreakerOpen from inner service (lines 286-295) # --------------------------------------------------------------------------- @when("I async-call a function that raises inner CircuitBreakerOpen") def step_async_call_inner_cbo(context): """Async-call a function that raises CircuitBreakerOpen.""" async def async_inner_raises_cbo(): raise CircuitBreakerOpen("inner async service is open") context.caught_exception = None loop = asyncio.new_event_loop() try: loop.run_until_complete(context.cb.async_call(async_inner_raises_cbo)) except CircuitBreakerOpen as exc: context.caught_exception = exc finally: loop.close() # --------------------------------------------------------------------------- # When: async BaseException (KeyboardInterrupt) during HALF_OPEN # --------------------------------------------------------------------------- @when("I async-call a function that raises a KeyboardInterrupt") def step_async_call_keyboard_interrupt(context): """Async-call a function that raises KeyboardInterrupt.""" async def async_inner_raises_kbi(): raise KeyboardInterrupt("simulated async ctrl-c") context.caught_exception = None loop = asyncio.new_event_loop() try: loop.run_until_complete(context.cb.async_call(async_inner_raises_kbi)) except KeyboardInterrupt as exc: context.caught_exception = exc finally: loop.close() # --------------------------------------------------------------------------- # Then: verify exceptions # --------------------------------------------------------------------------- @then("the caught exception should be a CircuitBreakerOpen") def step_verify_cbo_raised(context): assert isinstance(context.caught_exception, CircuitBreakerOpen), ( f"Expected CircuitBreakerOpen, got {type(context.caught_exception).__name__}" ) @then("the caught exception should be a KeyboardInterrupt") def step_verify_kbi_raised(context): assert isinstance(context.caught_exception, KeyboardInterrupt), ( f"Expected KeyboardInterrupt, got {type(context.caught_exception).__name__}" ) @then("the coverage half-open permit count should be restored") def step_verify_coverage_permit_restored(context): """After the exception handler restores the permit, the count should be back to what it was before the call consumed it. """ assert context.cb._half_open_permits == context.permits_before, ( f"Expected permits={context.permits_before}, " f"got {context.cb._half_open_permits}" ) # --------------------------------------------------------------------------- # Stale generation discarded in _on_success (line 360) # --------------------------------------------------------------------------- @given("a coverage circuit breaker in half-open state with generation {gen:d}") def step_breaker_half_open_with_generation(context, gen): """Create a breaker and directly set it to HALF_OPEN with a known generation counter so we can test stale-generation logic. """ context.cb = CircuitBreaker( failure_threshold=3, recovery_timeout=60.0, expected_exception=RuntimeError, half_open_max_successes=2, name="stale-gen-test", ) # Directly manipulate internal state for precise control. context.cb.state = CircuitBreakerState.HALF_OPEN context.cb._half_open_generation = gen context.cb.success_count_half_open = 0 @when("_on_success is called with stale generation {stale_gen:d}") def step_call_on_success_stale(context, stale_gen): """Call _on_success with a generation that does not match the current one.""" context.on_success_result = context.cb._on_success(generation=stale_gen) @then("the _on_success result should be False") def step_verify_on_success_returns_false(context): assert context.on_success_result is False, ( f"Expected False, got {context.on_success_result}" ) @then("the coverage circuit breaker should still be half-open") def step_verify_still_half_open(context): assert context.cb.state == CircuitBreakerState.HALF_OPEN, ( f"Expected HALF_OPEN, got {context.cb.state}" ) # --------------------------------------------------------------------------- # _on_failure when already OPEN (line 384) # --------------------------------------------------------------------------- @given("a coverage circuit breaker that is already in the open state") def step_breaker_already_open(context): """Create a breaker and force it into the OPEN state.""" context.cb = CircuitBreaker( failure_threshold=3, recovery_timeout=60.0, expected_exception=RuntimeError, name="already-open-test", ) context.cb.state = CircuitBreakerState.OPEN context.cb.failure_count = 5 context.cb.last_failure_time = time.monotonic() @when("_on_failure is called on the already-open breaker") def step_call_on_failure_while_open(context): """Call _on_failure when the circuit is already OPEN.""" context.original_failure_count = context.cb.failure_count context.on_failure_result = context.cb._on_failure(generation=0) @then("the _on_failure result should be False") def step_verify_on_failure_returns_false(context): assert context.on_failure_result is False, ( f"Expected False, got {context.on_failure_result}" ) @then("the coverage circuit breaker state should still be open") def step_verify_still_open(context): assert context.cb.state == CircuitBreakerState.OPEN, ( f"Expected OPEN, got {context.cb.state}" ) @then("the failure count should not have changed") def step_verify_failure_count_unchanged(context): assert context.cb.failure_count == context.original_failure_count, ( f"Expected failure_count={context.original_failure_count}, " f"got {context.cb.failure_count}" )