"""Step definitions for circuit breaker behavior tests.""" from __future__ import annotations import contextlib from typing import Any from behave import given, then, when from cleveragents.core.retry_patterns import CircuitBreakerOpen, CircuitBreakerState # --------------------------------------------------------------------------- # Circuit breaker wiring steps # --------------------------------------------------------------------------- @given('the circuit breaker for "{name}" is open') def step_set_cb_open(context: Any, name: str) -> None: cb = context.wiring.get_circuit_breaker(name) assert cb is not None, f"No circuit breaker for {name}" cb.state = CircuitBreakerState.OPEN cb.failure_count = cb.failure_threshold import time cb.last_failure_time = time.monotonic() @then("a CircuitBreakerOpen exception should be raised") def step_check_cb_open_error(context: Any) -> None: assert isinstance(context.exec_error, CircuitBreakerOpen) @when('I reset the circuit breaker for "{name}"') def step_reset_cb(context: Any, name: str) -> None: context.wiring.reset_circuit(name) @then('the circuit breaker for "{name}" should be closed') def step_check_cb_closed(context: Any, name: str) -> None: cb = context.wiring.get_circuit_breaker(name) assert cb is not None assert cb.state == "closed" @when('I check the circuit status for "{name}"') def step_check_circuit_status(context: Any, name: str) -> None: context.circuit_open = context.wiring.is_circuit_open(name) @then("the circuit should not be open") def step_check_circuit_not_open(context: Any) -> None: assert context.circuit_open is False @given('the circuit breaker for "{name}" is in half-open state') def step_set_cb_half_open(context: Any, name: str) -> None: cb = context.wiring.get_circuit_breaker(name) assert cb is not None, f"No circuit breaker for {name}" cb.state = CircuitBreakerState.HALF_OPEN cb.success_count_half_open = 0 cb.failure_count = 0 cb._half_open_permits = cb.half_open_max_successes @when('I execute two successful calls through the wiring for "{name}"') def step_execute_two_successes(context: Any, name: str) -> None: # Execute directly through the circuit breaker to avoid retry decorator cb = context.wiring.get_circuit_breaker(name) if cb is not None: cb.call(lambda: "ok") cb.call(lambda: "ok") # --------------------------------------------------------------------------- # Circuit breaker config wiring (C2, C3) # --------------------------------------------------------------------------- @then('the circuit breaker for "{name}" should have cooldown_seconds {value:g}') def step_check_cb_cooldown_wired(context: Any, name: str, value: float) -> None: cb = context.wiring.get_circuit_breaker(name) assert cb is not None assert cb.cooldown_seconds == value @then('the circuit breaker for "{name}" should have half_open_max_successes {value:d}') def step_check_cb_half_open_max_wired(context: Any, name: str, value: int) -> None: cb = context.wiring.get_circuit_breaker(name) assert cb is not None assert cb.half_open_max_successes == value # --------------------------------------------------------------------------- # Circuit breaker last_failure_time cleared # --------------------------------------------------------------------------- @then('the circuit breaker for "{name}" last_failure_time should be None') def step_check_cb_last_failure_cleared(context: Any, name: str) -> None: cb = context.wiring.get_circuit_breaker(name) assert cb is not None assert cb.last_failure_time is None # --------------------------------------------------------------------------- # B1: _on_success does not close open circuit # --------------------------------------------------------------------------- @given("I have a standalone CircuitBreaker instance") def step_create_standalone_cb(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.standalone_cb = CircuitBreaker(failure_threshold=3) @given('the standalone circuit breaker state is set to "{state}"') def step_set_standalone_cb_state(context: Any, state: str) -> None: context.standalone_cb.state = CircuitBreakerState(state) context.standalone_cb.failure_count = context.standalone_cb.failure_threshold @when("I call _on_success on the standalone circuit breaker") def step_call_on_success_standalone(context: Any) -> None: with context.standalone_cb._lock: context.standalone_cb._on_success() @then('the standalone circuit breaker state should remain "{state}"') def step_check_standalone_cb_state_remain(context: Any, state: str) -> None: assert context.standalone_cb.state == state # --------------------------------------------------------------------------- # B2: Half-open probe limit # --------------------------------------------------------------------------- @given("I have a standalone CircuitBreaker instance in half-open with 0 permits") def step_create_cb_half_open_no_permits(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.standalone_cb = CircuitBreaker(failure_threshold=3) context.standalone_cb.state = CircuitBreakerState.HALF_OPEN context.standalone_cb._half_open_permits = 0 @when("I call the standalone circuit breaker with a test function") def step_call_standalone_cb(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreakerOpen try: context.standalone_cb.call(lambda: "ok") context.standalone_cb_error = None except CircuitBreakerOpen as exc: context.standalone_cb_error = exc @then("a CircuitBreakerOpen exception should be raised for probe limit") def step_check_probe_limit_error(context: Any) -> None: assert context.standalone_cb_error is not None assert "probe limit" in str(context.standalone_cb_error) # --------------------------------------------------------------------------- # B4: Non-expected exceptions tracked by circuit breaker # --------------------------------------------------------------------------- @given("I have a standalone CircuitBreaker with expected_exception IOError") def step_create_cb_with_ioerror(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.standalone_cb = CircuitBreaker( failure_threshold=5, expected_exception=IOError ) @when("I call it with a function that raises RuntimeError") def step_call_cb_with_runtime_error(context: Any) -> None: def raise_runtime() -> str: raise RuntimeError("boom") with contextlib.suppress(RuntimeError): context.standalone_cb.call(raise_runtime) @when("I call it with a function that raises IOError") def step_call_cb_with_ioerror(context: Any) -> None: def raise_io() -> str: raise OSError("boom") with contextlib.suppress(OSError): context.standalone_cb.call(raise_io) @then("the standalone circuit breaker failure_count should be {count:d}") def step_check_standalone_cb_failure_count(context: Any, count: int) -> None: assert context.standalone_cb.failure_count == count # --------------------------------------------------------------------------- # T-A2: Half-open failure re-opens circuit # --------------------------------------------------------------------------- @given("I have a standalone CircuitBreaker instance in half-open state with permits") def step_create_cb_half_open_with_permits(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.standalone_cb = CircuitBreaker(failure_threshold=3) context.standalone_cb.state = CircuitBreakerState.HALF_OPEN context.standalone_cb._half_open_permits = 2 context.standalone_cb.success_count_half_open = 0 @when("I call it with a function that raises an expected exception") def step_call_cb_with_expected_exception(context: Any) -> None: def failing_func() -> str: raise RuntimeError("half-open probe failed") with contextlib.suppress(RuntimeError): context.standalone_cb.call(failing_func) @then('the standalone circuit breaker state should be "{state}"') def step_check_standalone_state(context: Any, state: str) -> None: assert context.standalone_cb.state == state # --------------------------------------------------------------------------- # T-A3: Cooldown prevents premature reset # --------------------------------------------------------------------------- @given("I have a standalone CircuitBreaker with a recent half-open attempt") def step_create_cb_with_recent_half_open(context: Any) -> None: import time from cleveragents.core.retry_patterns import CircuitBreaker context.standalone_cb = CircuitBreaker( failure_threshold=3, recovery_timeout=1.0, cooldown_seconds=9999.0, # Very long cooldown ) context.standalone_cb.state = CircuitBreakerState.OPEN context.standalone_cb.failure_count = 3 context.standalone_cb.last_failure_time = time.monotonic() - 10.0 # Past recovery context.standalone_cb._last_half_open_time = time.monotonic() # Just attempted @when("I check _should_attempt_reset on the standalone circuit breaker") def step_check_should_attempt_reset(context: Any) -> None: context.reset_allowed = context.standalone_cb._should_attempt_reset() @then("the reset should be denied due to cooldown") def step_check_reset_denied(context: Any) -> None: assert context.reset_allowed is False # --------------------------------------------------------------------------- # M6: failure_count reset on half-open entry # --------------------------------------------------------------------------- @given("I have a standalone CircuitBreaker that transitions to half-open") def step_create_cb_for_half_open_transition(context: Any) -> None: import time as _time from cleveragents.core.retry_patterns import CircuitBreaker context.standalone_cb = CircuitBreaker( failure_threshold=3, recovery_timeout=0.01, cooldown_seconds=0.0, ) # Simulate an open circuit with failures # S5: circuit breaker now uses time.monotonic() internally context.standalone_cb.state = CircuitBreakerState.OPEN context.standalone_cb.failure_count = 5 context.standalone_cb.last_failure_time = _time.monotonic() - 1.0 @when("the circuit breaker transitions from open to half-open") def step_transition_to_half_open(context: Any) -> None: # Calling a successful function should trigger open -> half-open transition result = context.standalone_cb.call(lambda: "probe_ok") context.half_open_transition_result = result @then("the circuit breaker failure_count should be {count:d}") def step_check_cb_failure_count_after_transition(context: Any, count: int) -> None: assert context.standalone_cb.failure_count == count # --------------------------------------------------------------------------- # Constructor validation: half_open_max_successes # --------------------------------------------------------------------------- @when("I create a CircuitBreaker with half_open_max_successes {value:d}") def step_create_cb_invalid_half_open(context: Any, value: int) -> None: from cleveragents.core.retry_patterns import CircuitBreaker try: CircuitBreaker(failure_threshold=3, half_open_max_successes=value) context.cb_ctor_error = None except ValueError as exc: context.cb_ctor_error = exc @then("a ValueError should be raised for invalid half_open_max_successes") def step_check_cb_ctor_value_error(context: Any) -> None: assert isinstance(context.cb_ctor_error, ValueError) assert "half_open_max_successes" in str(context.cb_ctor_error) # --------------------------------------------------------------------------- # _should_attempt_reset with None last_failure_time # --------------------------------------------------------------------------- @given("I have a fresh circuit breaker with no failures") def step_create_fresh_cb(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.fresh_cb = CircuitBreaker(failure_threshold=3) # Ensure no failures have been recorded assert context.fresh_cb.last_failure_time is None @when("I check _should_attempt_reset on the fresh circuit breaker") def step_check_fresh_should_attempt_reset(context: Any) -> None: context.fresh_reset_result = context.fresh_cb._should_attempt_reset() @then("_should_attempt_reset should return False") def step_verify_fresh_reset_false(context: Any) -> None: assert context.fresh_reset_result is False # --------------------------------------------------------------------------- # Async half-open probe limit # --------------------------------------------------------------------------- @given("I have an async circuit breaker in half-open with 0 permits") def step_create_async_cb_half_open_no_permits(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.async_cb = CircuitBreaker(failure_threshold=3) context.async_cb.state = CircuitBreakerState.HALF_OPEN context.async_cb._half_open_permits = 0 @when("I make an async call on the half-open circuit breaker") def step_async_call_half_open_no_permits(context: Any) -> None: import asyncio as _asyncio from cleveragents.core.retry_patterns import CircuitBreakerOpen async def _do() -> None: await context.async_cb.async_call(lambda: "ok") loop = _asyncio.new_event_loop() try: loop.run_until_complete(_do()) context.async_probe_error = None except CircuitBreakerOpen as exc: context.async_probe_error = exc finally: loop.close() @then("a CircuitBreakerOpen should be raised for async probe limit") def step_check_async_probe_limit(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreakerOpen assert isinstance(context.async_probe_error, CircuitBreakerOpen) assert "probe limit" in str(context.async_probe_error) # --------------------------------------------------------------------------- # Async CancelledError restores permits # --------------------------------------------------------------------------- @given("I have an async circuit breaker in half-open with 1 permit") def step_create_async_cb_half_open_one_permit(context: Any) -> None: from cleveragents.core.retry_patterns import CircuitBreaker context.async_cb = CircuitBreaker(failure_threshold=3) context.async_cb.state = CircuitBreakerState.HALF_OPEN context.async_cb._half_open_permits = 1 @when("the async call is cancelled mid-execution") def step_async_call_cancelled(context: Any) -> None: import asyncio as _asyncio async def cancelling_func() -> str: raise _asyncio.CancelledError() loop = _asyncio.new_event_loop() try: loop.run_until_complete(context.async_cb.async_call(cancelling_func)) context.cancel_error = None except _asyncio.CancelledError: context.cancel_error = "cancelled" finally: loop.close() @then("the half-open permit should be restored") def step_check_permit_restored(context: Any) -> None: assert context.cancel_error == "cancelled" # The permit was decremented on entry and restored on CancelledError assert context.async_cb._half_open_permits == 1