"""Step definitions for ServiceRetryWiring execute and async tests.""" from __future__ import annotations import asyncio import contextlib from typing import Any from behave import given, then, when from cleveragents.config.settings import Settings from cleveragents.core.retry_patterns import CircuitBreakerOpen @given("I have default Settings") def step_default_settings(context: Any) -> None: context.settings = Settings( database_url="sqlite:///test_retry.db", mock_providers=True, env="test", ) @when("I create a ServiceRetryWiring from Settings") def step_create_wiring(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, ) context.wiring = ServiceRetryWiring(context.settings) @then("the wiring should have circuit breakers for known services") def step_check_wiring_cbs(context: Any) -> None: cb = context.wiring.get_circuit_breaker("plan_service") assert cb is not None @then("the wiring registry should contain known service policies") def step_check_wiring_registry(context: Any) -> None: services = context.wiring.registry.registered_services() assert "plan_service" in services assert "session_service" in services @given("I have a ServiceRetryWiring instance") def step_create_wiring_instance(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, ) settings = Settings( database_url="sqlite:///test_retry.db", mock_providers=True, env="test", ) context.wiring = ServiceRetryWiring(settings) @given("I have a function that fails once then succeeds") def step_create_fail_once_func(context: Any) -> None: context.call_count = 0 def fail_once() -> str: context.call_count += 1 if context.call_count <= 1: raise RuntimeError("Transient failure") return "success" context.test_func = fail_once @when("I execute the function through the wiring as an idempotent operation") def step_execute_idempotent(context: Any) -> None: try: context.result = context.wiring.execute( service_name="plan_service", operation_name="test_op", func=context.test_func, idempotent=True, ) context.exec_error = None except Exception as exc: context.exec_error = exc context.result = None @then("the function should succeed after retry") def step_check_succeeded(context: Any) -> None: assert context.exec_error is None assert context.result == "success" @then("the function should have been called {n:d} times") def step_check_call_count(context: Any, n: int) -> None: assert context.call_count == n @given("I have a function that fails on first call") def step_create_fail_first(context: Any) -> None: context.call_count = 0 def fail_first() -> str: context.call_count += 1 raise RuntimeError("Permanent failure") context.test_func = fail_first @when("I execute the function through the wiring as a non-idempotent operation") def step_execute_non_idempotent(context: Any) -> None: try: context.result = context.wiring.execute( service_name="plan_service", operation_name="test_write_op", func=context.test_func, idempotent=False, ) context.exec_error = None except Exception as exc: context.exec_error = exc context.result = None @then("the function should fail immediately") def step_check_failed(context: Any) -> None: assert context.exec_error is not None @then("the function should have been called exactly {n:d} time") def step_check_exact_calls(context: Any, n: int) -> None: assert context.call_count == n @when('I execute a function through the wiring for "{name}"') def step_execute_with_cb(context: Any, name: str) -> None: try: context.result = context.wiring.execute( service_name=name, operation_name="test_op", func=lambda: "ok", idempotent=True, ) context.exec_error = None except CircuitBreakerOpen as exc: context.exec_error = exc except Exception as exc: context.exec_error = exc @when('I get a decorator from wrap_service_method for "{name}"') def step_get_decorator(context: Any, name: str) -> None: context.decorator = context.wiring.wrap_service_method( name, "test_op", idempotent=True ) @then("the decorator should be callable") def step_check_decorator_callable(context: Any) -> None: assert callable(context.decorator) @then("the settings should have {field_name} field") def step_check_settings_field(context: Any, field_name: str) -> None: assert hasattr(context.settings, field_name) @given( "I have Settings with retry_service_overrides setting plan_service max_attempts to {value:d}" ) def step_settings_with_overrides(context: Any, value: int) -> None: import json import os overrides = json.dumps({"plan_service": {"retry": {"max_attempts": value}}}) # Settings uses validation_alias so the field must be set via env var os.environ["CLEVERAGENTS_RETRY_SERVICE_OVERRIDES"] = overrides try: context.settings = Settings( database_url="sqlite:///test_retry.db", mock_providers=True, env="test", ) finally: os.environ.pop("CLEVERAGENTS_RETRY_SERVICE_OVERRIDES", None) @when("I create a ServiceRetryWiring from those retry Settings") def step_create_wiring_from_settings(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, ) context.wiring = ServiceRetryWiring(context.settings) @when("I execute the function through the wiring with logging captured") def step_execute_with_logging(context: Any) -> None: import structlog import cleveragents.core.retry_service_patterns as _rsp_mod # U8: Capture actual structlog output so the Then step can verify # that structured retry log messages were emitted. captured_entries: list[dict[str, Any]] = [] def _capture_processor(_logger: Any, _method: str, event_dict: Any) -> Any: captured_entries.append(event_dict.copy()) raise structlog.DropEvent old_config = structlog.get_config() structlog.configure( processors=[_capture_processor], wrapper_class=structlog.stdlib.BoundLogger, context_class=dict, logger_factory=structlog.PrintLoggerFactory(), cache_logger_on_first_use=False, ) # Replace the module-level logger in retry_service_patterns with a # fresh lazy proxy. When configure_structlog() (called during # container init in before_all) sets cache_logger_on_first_use=True, # earlier scenarios cause the proxy to cache a resolved BoundLogger # that ignores subsequent structlog.configure() calls. Creating a # new proxy ensures our capture processor is used. saved_rsp_logger = _rsp_mod.logger _rsp_mod.logger = structlog.get_logger(_rsp_mod.__name__) try: context.result = context.wiring.execute( service_name="plan_service", operation_name="test_logged_op", func=context.test_func, idempotent=True, ) context.exec_error = None except Exception as exc: context.exec_error = exc finally: _rsp_mod.logger = saved_rsp_logger structlog.configure(**old_config) context.captured_log_entries = captured_entries @then("structured retry log messages should be present") def step_check_log_messages(context: Any) -> None: # The retry infrastructure emits logs on retry attempts. # Verify the operation succeeded (meaning retries happened). assert context.exec_error is None assert context.result == "success" assert context.call_count >= 2 # U8: Verify that at least one retry-related log entry was captured. retry_entries = [ e for e in context.captured_log_entries if "retry" in str(e.get("event", "")).lower() or e.get("operation") == "test_logged_op" ] assert len(retry_entries) > 0, ( f"No retry log entries found in {context.captured_log_entries}" ) @given("I have a function that always fails with RuntimeError") def step_create_always_failing_func(context: Any) -> None: context.always_fail_count = 0 def always_fail() -> str: context.always_fail_count += 1 raise RuntimeError("Persistent failure") context.always_fail_func = always_fail @when("I execute the always-failing function through the wiring as idempotent") def step_execute_always_failing(context: Any) -> None: try: context.result = context.wiring.execute( service_name="plan_service", operation_name="test_exhaustion", func=context.always_fail_func, idempotent=True, ) context.exhaustion_error = None except RuntimeError as exc: context.exhaustion_error = exc except Exception as exc: context.exhaustion_error = exc @then("the RuntimeError should propagate after exhausting retries") def step_check_exhaustion_error(context: Any) -> None: assert isinstance(context.exhaustion_error, RuntimeError) assert "Persistent failure" in str(context.exhaustion_error) @then("the always-failing function should have been called max_attempts times") def step_check_always_fail_call_count(context: Any) -> None: policy = context.wiring.get_policy("plan_service") assert context.always_fail_count == policy.retry.max_attempts @when("I decorate and call a function via wrap_service_method") def step_wrap_and_call(context: Any) -> None: decorator = context.wiring.wrap_service_method( "session_service", "test_wrap_exec", idempotent=True ) @decorator def sample_func() -> str: return "wrapped_result" context.wrap_result = sample_func() @then("the wrapped function should return its expected value") def step_check_wrap_result(context: Any) -> None: assert context.wrap_result == "wrapped_result" @given('I have Settings with retry_service_overrides set to "{raw_json}"') def step_settings_with_invalid_overrides(context: Any, raw_json: str) -> None: import os os.environ["CLEVERAGENTS_RETRY_SERVICE_OVERRIDES"] = raw_json try: context.settings = Settings( database_url="sqlite:///test_retry.db", mock_providers=True, env="test", ) finally: os.environ.pop("CLEVERAGENTS_RETRY_SERVICE_OVERRIDES", None) @when("I create a ServiceRetryWiring from those gracefully-invalid Settings") def step_create_wiring_invalid_overrides(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, ) context.graceful_error = None try: context.wiring = ServiceRetryWiring(context.settings) except Exception as exc: context.graceful_error = exc @then("the wiring should initialise without errors") def step_check_graceful_init(context: Any) -> None: assert context.graceful_error is None assert context.wiring is not None @given("I have an async function that fails once then succeeds") def step_create_async_fail_once(context: Any) -> None: context.async_call_count = 0 async def async_fail_once() -> str: context.async_call_count += 1 if context.async_call_count <= 1: raise RuntimeError("Transient async failure") return "async_success" context.async_test_func = async_fail_once @when("I execute the async function through async_execute as idempotent") def step_execute_async_idempotent(context: Any) -> None: loop = asyncio.new_event_loop() try: context.async_result = loop.run_until_complete( context.wiring.async_execute( service_name="plan_service", operation_name="test_async_op", func=context.async_test_func, idempotent=True, ) ) context.async_exec_error = None except Exception as exc: context.async_exec_error = exc context.async_result = None finally: loop.close() @then("the async function should succeed after retry") def step_check_async_succeeded(context: Any) -> None: assert context.async_exec_error is None assert context.async_result == "async_success" @then("the async function should have been called {n:d} times") def step_check_async_call_count(context: Any, n: int) -> None: assert context.async_call_count == n @given("I have an async function that always fails") def step_create_async_always_fails(context: Any) -> None: context.async_call_count = 0 async def async_always_fail() -> str: context.async_call_count += 1 raise RuntimeError("Permanent async failure") context.async_test_func = async_always_fail @when("I execute the async function through async_execute as non-idempotent") def step_execute_async_non_idempotent(context: Any) -> None: loop = asyncio.new_event_loop() try: context.async_result = loop.run_until_complete( context.wiring.async_execute( service_name="plan_service", operation_name="test_async_write", func=context.async_test_func, idempotent=False, ) ) context.async_exec_error = None except RuntimeError as exc: context.async_exec_error = exc context.async_result = None finally: loop.close() @then("the async function should fail immediately with RuntimeError") def step_check_async_failed_immediately(context: Any) -> None: assert isinstance(context.async_exec_error, RuntimeError) @then("the async function call count should be {n:d}") def step_check_async_fail_count(context: Any, n: int) -> None: assert context.async_call_count == n @given("I have a ServiceRetryWiring instance for a service without circuit breaker") def step_create_wiring_no_cb(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, ) settings = Settings( database_url="sqlite:///test_retry.db", mock_providers=True, env="test", ) context.wiring = ServiceRetryWiring(settings) # Manually remove the CB for a service to test the None path context.wiring._circuit_breakers.pop("plan_service", None) @when("I execute a function through the wiring for the unprotected service") def step_execute_no_cb(context: Any) -> None: try: context.result = context.wiring.execute( service_name="plan_service", operation_name="test_no_cb", func=lambda: "no_cb_result", idempotent=True, ) context.exec_error = None except Exception as exc: context.exec_error = exc @then("the function should execute successfully without circuit breaker") def step_check_no_cb_success(context: Any) -> None: assert context.exec_error is None assert context.result == "no_cb_result" @given('I have Settings with non-default retry_backoff_strategy "{strategy}"') def step_settings_backoff_strategy(context: Any, strategy: str) -> None: import os os.environ["CLEVERAGENTS_RETRY_BACKOFF_STRATEGY"] = strategy try: context.settings = Settings( database_url="sqlite:///test_retry.db", mock_providers=True, env="test", ) finally: os.environ.pop("CLEVERAGENTS_RETRY_BACKOFF_STRATEGY", None) @when("I create a ServiceRetryWiring from those backoff-strategy Settings") def step_create_wiring_backoff(context: Any) -> None: from cleveragents.application.services.service_retry_wiring import ( ServiceRetryWiring, ) context.wiring = ServiceRetryWiring(context.settings) @when("I execute a nested retry operation through the wiring") def step_execute_nested_retry(context: Any) -> None: context.inner_call_count = 0 context.outer_call_count = 0 def inner_func() -> str: context.inner_call_count += 1 raise RuntimeError("inner failure") def outer_func() -> str: context.outer_call_count += 1 # This nested execute should NOT retry inner_func with contextlib.suppress(RuntimeError): context.wiring.execute( service_name="session_service", operation_name="nested_inner", func=inner_func, idempotent=True, ) return "outer_ok" context.nested_result = context.wiring.execute( service_name="plan_service", operation_name="nested_outer", func=outer_func, idempotent=True, ) @then("the inner function should have been called only once per outer attempt") def step_check_inner_call_count(context: Any) -> None: # Inner should have been called exactly once per outer call # (no retries due to nesting guard) assert context.inner_call_count == context.outer_call_count