4d3499dcfb
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m43s
CI / integration_tests (pull_request) Successful in 3m20s
CI / docker (pull_request) Successful in 51s
CI / coverage (pull_request) Successful in 6m18s
CI / build (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / quality (push) Successful in 29s
CI / typecheck (push) Successful in 35s
CI / security (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m17s
CI / docker (push) Successful in 9s
CI / integration_tests (push) Successful in 3m29s
CI / coverage (push) Successful in 7m3s
CI / benchmark-publish (push) Successful in 19m50s
CI / benchmark-regression (pull_request) Successful in 40m6s
Wire per-service retry policies and circuit breakers into the service layer via ServiceRetryWiring, backed by ServiceRetryPolicyRegistry and configurable through Settings environment variables. Production hardening from code review: - Fix TOCTOU race in CircuitBreaker._on_success (half-open state) - Add half-open probe limit to prevent unbounded concurrent requests - Track all exception types for circuit breaker failure counting - Detect async callables wrapped in functools.partial and callable objects - Enforce spec-compliant 2s minimum for linear backoff strategy - Enforce 0.1s floor for fixed backoff strategy - Add retry amplification guard via contextvars nesting depth tracking - Cap total retry wall-clock time at 300s (MAX_RETRY_TOTAL_TIMEOUT) - Sanitize exception messages in retry logs to prevent secret leakage - Fix wrap_service_method TOCTOU by holding cache lock for full operation - Deep-copy default policies to prevent cross-policy mutation - Warn on unknown override keys in apply_overrides - Guard apply_overrides against non-dict and deeply nested JSON values - Read circuit breaker state under lock in is_circuit_open - Catch RecursionError in JSON config parsing - Add total_timeout + nesting guard to retry_service_operation decorator - Extend secret sanitization to Authorization headers, private_key, connection_string, and access_key patterns - Enforce 0.1s floor on jitter backoff strategy - Cache wait strategies per service in ServiceRetryWiring (M3) - Reset failure_count to 0 when entering half-open from open (M6) - Use cached _get_wait_strategy() in execute()/async_execute() - Move circuit-open logging out of _on_failure lock scope to prevent holding the lock during potentially slow I/O (F1) - Pass total_timeout=MAX_RETRY_TOTAL_TIMEOUT to wrap_service_method retry_service_operation call for consistency with execute() (F4) - Capture failure_count into local variable inside lock scope before logging outside the lock, preventing stale reads from concurrent threads in CircuitBreaker.call() and async_call() (F1) - Deep-copy module-level DEFAULT_DATABASE_RETRY and DEFAULT_CIRCUIT_BREAKER in ServiceRetryPolicyRegistry.get() for auto-generated unknown service policies, preventing shared mutable state corruption (F1) - Unify CircuitBreaker to a single threading.Lock for sync and async (P1-1) - Restore BaseException permit in half-open path to prevent permit leak (P1-6) - Prevent CircuitBreakerOpen cascading into failure_count (S2) - Protect all logger calls with contextlib.suppress (S3, S4) - Replace time.time() with time.monotonic() for monotonic timing (S5) - Add distinct log events for half-open and closed transitions (S11, S12) - Track pre-existing services so second apply_settings_defaults only targets newly registered services (P1-2) - Lazy circuit breaker creation via _get_or_create_cb() (P1-3) - Reject async callables in sync execute() with TypeError (P1-5) - Strengthen retry predicate to retry_if_exception_type(Exception) & retry_if_not_exception_type(CircuitBreakerOpen) (S1) - Add lock on _get_wait_strategy cache access (P2-16) - Truncate raw JSON to 80 chars in override warning (P2-17) - Warn on non-dict JSON overrides (P2-29) - Debug log for nesting guard bypass (S13) - Deep-copy from get() and all_policies() in registry (P1-4) - Thread-safe registry with threading.Lock (P2-15) - Robust exception handling in apply_overrides get() (P2-18) - Log ValidationError details on override failure (P2-19) - Sanitize service_name via _safe_service_name() (P2-28) - Warn on non-dict sub-key values in overrides (P2-30) - Allowlist for is_read_only_plan_operation phases (P2-10) - Cap retry_auto_debug sleep at 60s (P2-11) - Use is-not-None instead of falsy checks for error values (P2-12) - Extend secret regex with bearer, session_id, auth_token, refresh_token, client_secret patterns (P2-25) - Pre-truncate error messages to 2000 chars before regex (P2-26) - Add upper bounds on retry Settings fields (P2-7) - Add cross-field validator max_delay >= base_delay (P2-8) - Case-insensitive backoff strategy validation (P2-21) - Add half_open_max_successes setting (S10) - Remove phantom ContextFragment from services __all__ (ImportError fix) - Export ServiceRetryWiring from application.services package - Include sanitised error context in TypeError logging fallback - Initialise RetryContext.attempt_count to 1 for bare context-manager usage - Introduce CircuitBreakerState StrEnum replacing raw string literals - Fix vacuous CircuitBreakerOpen propagation assertions in BDD steps - Replace tautological logging test with structlog capture verification - Assert circuit breaker existence instead of silently skipping on None - Add Unicode control-char rejection validator to ServiceRetryPolicy.service_name - Add name parameter with service= in all log calls - Add extra="forbid" to all 3 Pydantic models - Deep-copy _SERVICE_DEFAULTS construction - Key normalisation (.strip()) in get() and apply_overrides() - Add cooldown <= recovery_timeout validator - Async guard on RetryContext.execute() - Nesting guard on RetryContext.execute()/async_execute() - stop_after_delay(300.0) on RetryContext - retry_auto_debug async-only guard, dict result fix, sleep guard - Retry-attempt logging in RetryContext - Module-level docs for contextlib.suppress(TypeError) rationale - Exhaustion log on retry failure - Startup log in __init__; name=service_name to CircuitBreaker - log_after_retry guarded to not fire on first-attempt success - get_retry_decorator now includes logging callbacks - Changed retry_backoff_strategy from str to RetryStrategy StrEnum Closes #313
1209 lines
42 KiB
Python
1209 lines
42 KiB
Python
"""Step definitions for retry patterns feature tests."""
|
|
|
|
import asyncio
|
|
import builtins
|
|
import contextlib
|
|
import logging
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.core import retry_patterns as retry_patterns_module
|
|
from cleveragents.core.exceptions import (
|
|
NetworkError,
|
|
RateLimitError,
|
|
)
|
|
from cleveragents.core.retry_patterns import (
|
|
RETRY_CATEGORIES,
|
|
CircuitBreaker,
|
|
CircuitBreakerOpen,
|
|
CircuitBreakerState,
|
|
RetryContext,
|
|
async_retry_with_exponential_backoff,
|
|
configure_retry_logging,
|
|
get_retry_decorator,
|
|
log_after_retry,
|
|
log_before_retry,
|
|
retry_auto_debug,
|
|
retry_file_operation,
|
|
retry_network_operation,
|
|
retry_on_result,
|
|
retry_provider_operation,
|
|
retry_with_exponential_backoff,
|
|
retry_with_jitter,
|
|
retry_with_timeout,
|
|
should_retry_result,
|
|
)
|
|
|
|
|
|
@given("I have the retry patterns module imported")
|
|
def step_retry_module_imported(context):
|
|
"""Verify retry patterns module is available."""
|
|
context.retry_module_available = True
|
|
assert retry_with_exponential_backoff is not None
|
|
assert CircuitBreaker is not None
|
|
assert RetryContext is not None
|
|
|
|
|
|
@given("I have a function that fails {num:d} times then succeeds")
|
|
def step_create_failing_function(context, num):
|
|
"""Create a function that fails specified number of times."""
|
|
context.fail_count = num
|
|
context.call_count = 0
|
|
|
|
def failing_function():
|
|
context.call_count += 1
|
|
if context.call_count <= context.fail_count:
|
|
raise Exception(f"Failure {context.call_count}")
|
|
return "success"
|
|
|
|
context.test_function = failing_function
|
|
|
|
|
|
@given("I have an async function that fails {num:d} times then succeeds")
|
|
def step_create_async_failing_function(context, num):
|
|
"""Create an async function that fails specified number of times."""
|
|
context.fail_count = num
|
|
context.call_count = 0
|
|
|
|
async def async_failing_function():
|
|
context.call_count += 1
|
|
if context.call_count <= context.fail_count:
|
|
raise Exception(f"Async failure {context.call_count}")
|
|
return "async success"
|
|
|
|
context.test_function = async_failing_function
|
|
|
|
|
|
@when("I apply exponential backoff retry with max {max_attempts:d} attempts")
|
|
def step_apply_exponential_retry(context, max_attempts):
|
|
"""Apply exponential backoff retry to function."""
|
|
decorated_func = retry_with_exponential_backoff(
|
|
max_attempts=max_attempts,
|
|
min_wait=0.01, # Short waits for testing
|
|
max_wait=0.1,
|
|
)(context.test_function)
|
|
|
|
try:
|
|
context.result = decorated_func()
|
|
context.retry_succeeded = True
|
|
except Exception as e:
|
|
context.retry_error = e
|
|
context.retry_succeeded = False
|
|
|
|
|
|
@when("I apply async exponential backoff retry with max {max_attempts:d} attempts")
|
|
def step_apply_async_exponential_retry(context, max_attempts):
|
|
"""Apply async exponential backoff retry to function."""
|
|
decorated_func = async_retry_with_exponential_backoff(
|
|
max_attempts=max_attempts, min_wait=0.01, max_wait=0.1
|
|
)(context.test_function)
|
|
|
|
async def run_async():
|
|
try:
|
|
result = await decorated_func()
|
|
return result, True
|
|
except Exception as e:
|
|
return e, False
|
|
|
|
loop = asyncio.new_event_loop()
|
|
result, succeeded = loop.run_until_complete(run_async())
|
|
loop.close()
|
|
|
|
if succeeded:
|
|
context.result = result
|
|
context.retry_succeeded = True
|
|
else:
|
|
context.retry_error = result
|
|
context.retry_succeeded = False
|
|
|
|
|
|
@then("the function should eventually succeed")
|
|
def step_verify_function_succeeded(context):
|
|
"""Verify function succeeded after retries."""
|
|
assert context.retry_succeeded, (
|
|
f"Function failed: {getattr(context, 'retry_error', 'Unknown error')}"
|
|
)
|
|
assert context.result == "success"
|
|
|
|
|
|
@then("the async function should eventually succeed")
|
|
def step_verify_async_function_succeeded(context):
|
|
"""Verify async function succeeded after retries."""
|
|
assert context.retry_succeeded, (
|
|
f"Async function failed: {getattr(context, 'retry_error', 'Unknown error')}"
|
|
)
|
|
assert context.result == "async success"
|
|
|
|
|
|
@then("the function should be called {expected_calls:d} times")
|
|
def step_verify_call_count(context, expected_calls):
|
|
"""Verify function was called expected number of times."""
|
|
assert context.call_count == expected_calls, (
|
|
f"Expected {expected_calls} calls, got {context.call_count}"
|
|
)
|
|
|
|
|
|
# Network retry pattern tests
|
|
@given("I have a function that raises NetworkError twice")
|
|
def step_create_network_error_function(context):
|
|
"""Create function that raises NetworkError."""
|
|
context.call_count = 0
|
|
|
|
def network_function():
|
|
context.call_count += 1
|
|
if context.call_count <= 2:
|
|
raise NetworkError(f"Network failure {context.call_count}")
|
|
return "network success"
|
|
|
|
context.test_function = network_function
|
|
|
|
|
|
@when("I apply network retry pattern")
|
|
def step_apply_network_retry(context):
|
|
"""Apply network-specific retry pattern."""
|
|
decorated_func = retry_network_operation(context.test_function)
|
|
|
|
try:
|
|
context.result = decorated_func()
|
|
context.retry_succeeded = True
|
|
except Exception as e:
|
|
context.retry_error = e
|
|
context.retry_succeeded = False
|
|
|
|
|
|
@then("the function should retry on NetworkError")
|
|
def step_verify_network_retry(context):
|
|
"""Verify function retried on NetworkError."""
|
|
assert context.retry_succeeded, "Network retry should have succeeded"
|
|
assert context.result == "network success"
|
|
|
|
|
|
@then("the function should be called {max_calls:d} times maximum")
|
|
def step_verify_max_calls(context, max_calls):
|
|
"""Verify function wasn't called more than max times."""
|
|
assert context.call_count <= max_calls, (
|
|
f"Function called {context.call_count} times, max is {max_calls}"
|
|
)
|
|
|
|
|
|
# Provider retry pattern tests
|
|
@given("I have a function that raises RateLimitError")
|
|
def step_create_rate_limit_function(context):
|
|
"""Create function that raises RateLimitError."""
|
|
context.call_count = 0
|
|
|
|
def provider_function():
|
|
context.call_count += 1
|
|
if context.call_count <= 2:
|
|
raise RateLimitError("Rate limit exceeded")
|
|
return "provider success"
|
|
|
|
context.test_function = provider_function
|
|
|
|
|
|
@when("I apply provider retry pattern")
|
|
def step_apply_provider_retry(context):
|
|
"""Apply provider-specific retry pattern."""
|
|
decorated_func = retry_provider_operation(context.test_function)
|
|
|
|
try:
|
|
context.result = decorated_func()
|
|
context.retry_succeeded = True
|
|
except Exception as e:
|
|
context.retry_error = e
|
|
context.retry_succeeded = False
|
|
|
|
|
|
@then("the function should retry with exponential jitter")
|
|
def step_verify_exponential_jitter(context):
|
|
"""Verify function used exponential backoff with jitter."""
|
|
assert context.retry_succeeded, "Provider retry should have succeeded"
|
|
assert context.result == "provider success"
|
|
# The jitter is implemented internally by tenacity
|
|
|
|
|
|
@then("the function should respect rate limit backoff")
|
|
def step_verify_rate_limit_backoff(context):
|
|
"""Verify rate limit backoff was respected."""
|
|
# Rate limit backoff is handled by the wait strategy
|
|
assert context.call_count >= 2, "Should have retried at least once"
|
|
|
|
|
|
# File operation retry pattern tests
|
|
@given("I have a file operation that fails with OSError once")
|
|
def step_create_file_operation(context):
|
|
"""Create a file operation that fails once before succeeding."""
|
|
context.file_call_count = 0
|
|
|
|
def file_operation():
|
|
context.file_call_count += 1
|
|
if context.file_call_count == 1:
|
|
raise OSError("transient file failure")
|
|
return "file success"
|
|
|
|
context.file_operation = file_operation
|
|
|
|
|
|
@when("I apply file retry pattern")
|
|
def step_apply_file_retry(context):
|
|
"""Apply the file retry decorator to the operation."""
|
|
decorated_func = retry_file_operation(context.file_operation)
|
|
|
|
try:
|
|
context.file_result = decorated_func()
|
|
context.file_retry_succeeded = True
|
|
except Exception as exc: # pragma: no cover - defensive branch
|
|
context.file_retry_error = exc
|
|
context.file_retry_succeeded = False
|
|
|
|
|
|
@then("the file operation should eventually succeed")
|
|
def step_verify_file_retry_success(context):
|
|
"""Verify the file operation eventually succeeds."""
|
|
assert context.file_retry_succeeded, (
|
|
f"File retry failed: {getattr(context, 'file_retry_error', 'Unknown error')}"
|
|
)
|
|
assert context.file_result == "file success"
|
|
|
|
|
|
@then("the file operation should be invoked {expected:d} times")
|
|
def step_verify_file_retry_invocations(context, expected):
|
|
"""Verify the file operation was invoked the expected number of times."""
|
|
assert context.file_call_count == expected, (
|
|
f"Expected {expected} calls, got {context.file_call_count}"
|
|
)
|
|
|
|
|
|
# Circuit breaker tests
|
|
@given("I have a circuit breaker with threshold {threshold:d}")
|
|
def step_create_circuit_breaker(context, threshold):
|
|
"""Create a circuit breaker with specified threshold."""
|
|
context.circuit_breaker = CircuitBreaker(
|
|
failure_threshold=threshold,
|
|
recovery_timeout=0.5, # Short timeout for testing
|
|
expected_exception=Exception,
|
|
)
|
|
context.failure_count = 0
|
|
|
|
|
|
@when("a function fails {failures:d} times")
|
|
def step_function_fails_multiple(context, failures):
|
|
"""Simulate function failures."""
|
|
|
|
def failing_function():
|
|
raise Exception("Test failure")
|
|
|
|
for _ in range(failures):
|
|
try:
|
|
context.circuit_breaker.call(failing_function)
|
|
except Exception:
|
|
context.failure_count += 1
|
|
|
|
|
|
@then("the circuit breaker should open")
|
|
def step_verify_circuit_open(context):
|
|
"""Verify circuit breaker is open."""
|
|
assert context.circuit_breaker.state == "open"
|
|
|
|
|
|
@then("subsequent calls should fail immediately")
|
|
def step_verify_immediate_failure(context):
|
|
"""Verify calls fail immediately when circuit is open."""
|
|
|
|
def test_function():
|
|
return "should not execute"
|
|
|
|
try:
|
|
context.circuit_breaker.call(test_function)
|
|
# If we get here, the circuit breaker didn't raise an exception
|
|
raise AssertionError(
|
|
"Circuit breaker should have raised CircuitBreakerOpen exception"
|
|
)
|
|
except CircuitBreakerOpen:
|
|
# This is the expected behavior - circuit is open
|
|
pass
|
|
except Exception as e:
|
|
# Unexpected exception
|
|
raise AssertionError(
|
|
f"Expected CircuitBreakerOpen but got: {type(e).__name__}: {e}"
|
|
) from e
|
|
|
|
|
|
@given("I have an open circuit breaker")
|
|
def step_create_open_circuit_breaker(context):
|
|
"""Create a circuit breaker in open state."""
|
|
context.circuit_breaker = CircuitBreaker(
|
|
failure_threshold=1, recovery_timeout=0.1, expected_exception=Exception
|
|
)
|
|
# Force circuit to open
|
|
with contextlib.suppress(builtins.BaseException):
|
|
context.circuit_breaker.call(lambda: 1 / 0)
|
|
assert context.circuit_breaker.state == "open"
|
|
|
|
|
|
@when("the recovery timeout expires")
|
|
def step_wait_recovery_timeout(context):
|
|
"""Wait for recovery timeout.
|
|
|
|
Uses ``time._original_sleep`` (the un-patched sleep) because the
|
|
CircuitBreaker checks real wall-clock time to decide whether the
|
|
recovery window has elapsed. The global sleep cap installed by
|
|
``environment.py`` would reduce this to 10ms, preventing the 100ms
|
|
recovery timeout from expiring.
|
|
"""
|
|
_real_sleep = getattr(time, "_original_sleep", time.sleep)
|
|
_real_sleep(0.2) # Wait longer than recovery timeout (0.1s)
|
|
|
|
|
|
@then("the circuit breaker should enter half-open state")
|
|
def step_verify_half_open_state(context):
|
|
"""Verify circuit breaker is in half-open state."""
|
|
|
|
# Attempting a call should transition to half-open
|
|
def success_function():
|
|
return "success"
|
|
|
|
try:
|
|
context.circuit_breaker.call(success_function)
|
|
context.call_succeeded = True
|
|
except CircuitBreakerOpen:
|
|
context.call_succeeded = False
|
|
|
|
|
|
@then("successful calls should close the circuit")
|
|
def step_verify_circuit_closed(context):
|
|
"""Verify successful calls close the circuit."""
|
|
assert context.call_succeeded, "Call should have succeeded"
|
|
|
|
# After one successful call, circuit should be in half-open state
|
|
# Need a second successful call to close it
|
|
def success_function():
|
|
return "success"
|
|
|
|
result = context.circuit_breaker.call(success_function)
|
|
assert result == "success", "Second call should succeed"
|
|
assert context.circuit_breaker.state == "closed"
|
|
|
|
|
|
@given("the circuit breaker has recorded failures")
|
|
def step_circuit_breaker_recorded_failures(context):
|
|
"""Simulate prior failures to exercise closed-state success path."""
|
|
context.circuit_breaker.failure_count = 3
|
|
context.circuit_breaker.state = CircuitBreakerState.CLOSED
|
|
context.circuit_breaker.last_failure_time = time.monotonic()
|
|
|
|
|
|
@when("I execute a successful call while the circuit is closed")
|
|
def step_success_call_closed_state(context):
|
|
"""Execute a successful call without half-open transition."""
|
|
context.closed_state_result = context.circuit_breaker.call(lambda: "closed success")
|
|
|
|
|
|
@then("the circuit breaker should reset failure tracking after success")
|
|
def step_verify_closed_state_success(context):
|
|
"""Ensure closed-state success resets counters."""
|
|
assert context.closed_state_result == "closed success"
|
|
assert context.circuit_breaker.failure_count == 0
|
|
assert context.circuit_breaker.state == "closed"
|
|
|
|
|
|
# Retry context tests
|
|
@given('I have a retry context for "{operation}"')
|
|
def step_create_retry_context(context, operation):
|
|
"""Create a retry context for an operation."""
|
|
context.retry_context = RetryContext(operation_name=operation, max_attempts=3)
|
|
|
|
|
|
@when("I execute a function that fails twice")
|
|
def step_execute_with_retry_context(context):
|
|
"""Execute function with retry context."""
|
|
context.execution_count = 0
|
|
context.recorded_errors = []
|
|
|
|
def failing_function():
|
|
context.execution_count += 1
|
|
if context.execution_count <= 2:
|
|
error = Exception(f"Failure {context.execution_count}")
|
|
context.recorded_errors.append(error)
|
|
raise error
|
|
return "context success"
|
|
|
|
try:
|
|
context.result = context.retry_context.execute(failing_function)
|
|
context.execution_succeeded = True
|
|
except Exception as e:
|
|
context.execution_error = e
|
|
context.execution_succeeded = False
|
|
|
|
|
|
@then("the retry context should track {attempts:d} attempts")
|
|
def step_verify_retry_context_attempts(context, attempts):
|
|
"""Verify retry context tracked attempts."""
|
|
assert context.retry_context.attempt_count == attempts
|
|
|
|
|
|
@then("the errors should be recorded")
|
|
def step_verify_errors_recorded(context):
|
|
"""Verify errors were recorded in retry context."""
|
|
# Note: The current implementation doesn't fully track errors in execute()
|
|
# but the context manager pattern supports it
|
|
assert len(context.recorded_errors) >= 2
|
|
|
|
|
|
@when("I execute a failing block under the retry context manager")
|
|
def step_retry_context_manager_failure(context):
|
|
"""Execute a failing block under the retry context manager."""
|
|
errors_before = len(context.retry_context.errors)
|
|
try:
|
|
with context.retry_context:
|
|
raise RuntimeError("context manager failure")
|
|
except RuntimeError:
|
|
context.retry_context_manager_error = True
|
|
else: # pragma: no cover - defensive branch
|
|
context.retry_context_manager_error = False
|
|
context.retry_context_errors_added_sync = (
|
|
len(context.retry_context.errors) - errors_before
|
|
)
|
|
|
|
|
|
@then("the retry context manager should capture the exception")
|
|
def step_verify_retry_context_manager_capture(context):
|
|
"""Ensure the retry context manager captured the exception."""
|
|
assert context.retry_context_manager_error, "Expected runtime error to propagate"
|
|
assert context.retry_context_errors_added_sync >= 1
|
|
assert isinstance(context.retry_context.errors[-1], Exception)
|
|
|
|
|
|
@when("I execute a failing block under the async retry context manager")
|
|
def step_async_retry_context_manager_failure(context):
|
|
"""Execute a failing block under the async retry context manager."""
|
|
errors_before = len(context.retry_context.errors)
|
|
|
|
async def run_async_block():
|
|
try:
|
|
async with context.retry_context:
|
|
raise RuntimeError("async context failure")
|
|
except RuntimeError:
|
|
return True
|
|
return False
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.async_retry_context_manager_error = loop.run_until_complete(
|
|
run_async_block()
|
|
)
|
|
finally:
|
|
loop.close()
|
|
|
|
context.retry_context_errors_added_async = (
|
|
len(context.retry_context.errors) - errors_before
|
|
)
|
|
|
|
|
|
@then("the async retry context manager should capture the exception")
|
|
def step_verify_async_retry_context_manager_capture(context):
|
|
"""Ensure the async retry context manager captured the exception."""
|
|
assert context.async_retry_context_manager_error, (
|
|
"Expected async runtime error to propagate"
|
|
)
|
|
assert context.retry_context_errors_added_async >= 1
|
|
assert isinstance(context.retry_context.errors[-1], Exception)
|
|
|
|
|
|
@when("I execute a successful block under the retry context manager")
|
|
def step_retry_context_manager_success(context):
|
|
"""Execute a successful block to cover no-error path."""
|
|
errors_before = len(context.retry_context.errors)
|
|
with context.retry_context:
|
|
context.retry_context_success_value = "sync context success"
|
|
context.retry_context_errors_delta_sync = (
|
|
len(context.retry_context.errors) - errors_before
|
|
)
|
|
|
|
|
|
@then("the retry context manager should not record errors")
|
|
def step_verify_retry_context_manager_success(context):
|
|
"""Confirm no errors were recorded for successful context block."""
|
|
assert context.retry_context_success_value == "sync context success"
|
|
assert context.retry_context_errors_delta_sync == 0
|
|
|
|
|
|
@when("I execute a successful block under the async retry context manager")
|
|
def step_async_retry_context_manager_success(context):
|
|
"""Execute a successful async block to cover no-error path."""
|
|
errors_before = len(context.retry_context.errors)
|
|
|
|
async def run_async_block():
|
|
async with context.retry_context:
|
|
return "async context success"
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.async_retry_context_manager_result = loop.run_until_complete(
|
|
run_async_block()
|
|
)
|
|
finally:
|
|
loop.close()
|
|
|
|
context.retry_context_errors_delta_async = (
|
|
len(context.retry_context.errors) - errors_before
|
|
)
|
|
|
|
|
|
@then("the async retry context manager should not record errors")
|
|
def step_verify_async_retry_context_manager_success(context):
|
|
"""Confirm async context manager leaves errors untouched when successful."""
|
|
assert context.async_retry_context_manager_result == "async context success"
|
|
assert context.retry_context_errors_delta_async == 0
|
|
|
|
|
|
@when("I execute the async function with the retry context")
|
|
def step_execute_async_with_retry_context(context):
|
|
"""Execute async function using RetryContext.async_execute."""
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.async_execute_result = loop.run_until_complete(
|
|
context.retry_context.async_execute(context.test_function)
|
|
)
|
|
finally:
|
|
loop.close()
|
|
context.async_execute_attempts = context.retry_context.attempt_count
|
|
|
|
|
|
@then("the async retry context execute should succeed after {attempts:d} attempts")
|
|
def step_verify_async_execute_attempts(context, attempts):
|
|
"""Verify async execute succeeded after expected attempts."""
|
|
assert context.async_execute_result == "async success"
|
|
assert context.async_execute_attempts == attempts
|
|
|
|
|
|
@when("I apply retry with timeout of {max_attempts:d} attempts and {timeout} seconds")
|
|
def step_apply_retry_with_timeout(context, max_attempts, timeout):
|
|
"""Apply retry_with_timeout while stubbing out sleep and monotonic clock."""
|
|
timeout_seconds = float(timeout)
|
|
original_sleep = time.sleep
|
|
original_monotonic = time.monotonic
|
|
|
|
_fake_base = original_monotonic()
|
|
_call_counter = [0]
|
|
|
|
def _fake_monotonic():
|
|
_call_counter[0] += 1
|
|
return _fake_base + _call_counter[0] * 1e-6
|
|
|
|
time.sleep = lambda _seconds: None
|
|
time.monotonic = _fake_monotonic
|
|
|
|
def restore_sleep():
|
|
time.sleep = original_sleep
|
|
time.monotonic = original_monotonic
|
|
|
|
# Register cleanup first so it runs even if the step body fails.
|
|
context.add_cleanup(restore_sleep)
|
|
|
|
try:
|
|
decorated_func = retry_with_timeout(
|
|
max_attempts=max_attempts, timeout_seconds=timeout_seconds
|
|
)(context.test_function)
|
|
|
|
try:
|
|
context.result = decorated_func()
|
|
context.retry_succeeded = True
|
|
except Exception as exc: # pragma: no cover - defensive safety
|
|
context.retry_error = exc
|
|
context.retry_succeeded = False
|
|
finally:
|
|
# Eagerly restore time.sleep so subsequent scenarios in the same
|
|
# feature cannot observe the patched no-op — even if behave's
|
|
# add_cleanup fires slightly after scenario-boundary hooks.
|
|
restore_sleep()
|
|
|
|
|
|
# Retry with jitter tests
|
|
@given("I have multiple concurrent operations")
|
|
def step_setup_concurrent_operations(context):
|
|
"""Setup multiple operations for concurrent testing."""
|
|
context.operation_times = []
|
|
context.operation_count = 5
|
|
|
|
|
|
@when("I apply retry with jitter")
|
|
def step_apply_retry_with_jitter(context):
|
|
"""Apply retry with jitter to multiple operations."""
|
|
|
|
@retry_with_jitter(max_attempts=3, base_delay=0.01, max_delay=0.1)
|
|
def operation_with_jitter(op_id):
|
|
# First attempt always fails
|
|
if len([t for t in context.operation_times if t[0] == op_id]) == 0:
|
|
context.operation_times.append((op_id, time.monotonic_ns()))
|
|
raise Exception(f"Operation {op_id} failed")
|
|
return f"Operation {op_id} success"
|
|
|
|
# Execute operations
|
|
for i in range(context.operation_count):
|
|
with contextlib.suppress(builtins.BaseException):
|
|
operation_with_jitter(i)
|
|
|
|
|
|
@then("the retries should have random delays")
|
|
def step_verify_random_delays(context):
|
|
"""Verify retries have random delays (jitter)."""
|
|
# The jitter is applied internally by tenacity
|
|
# We can verify that operations were attempted
|
|
assert len(context.operation_times) > 0
|
|
|
|
|
|
@then("operations should not retry simultaneously")
|
|
def step_verify_no_simultaneous_retry(context):
|
|
"""Verify operations don't all share the exact same timestamp.
|
|
|
|
We use monotonic_ns() for high-resolution timestamps and count the
|
|
number of *distinct* timestamps. On every platform (even fast CI
|
|
runners with ``time.sleep`` patched to a no-op in a preceding
|
|
scenario) the sequential loop overhead ensures at least two distinct
|
|
nanosecond readings among the five operations, so we only assert
|
|
``>= 2``. The real jitter behaviour is validated by tenacity's own
|
|
test-suite; this scenario confirms the decorator wires up correctly.
|
|
"""
|
|
unique_times = len(set(t for _, t in context.operation_times))
|
|
assert unique_times >= 2, (
|
|
f"Expected at least 2 distinct timestamps, got {unique_times}"
|
|
)
|
|
|
|
|
|
# Retry on result tests
|
|
@given("I have sequential result payloads requiring a retry")
|
|
def step_setup_sequential_retry_results(context):
|
|
"""Create a deterministic sequence of results for retry_on_result tests."""
|
|
context.call_count = 0
|
|
context.result_sequence = [
|
|
{"retry": True, "value": "try-again"},
|
|
{"retry": False, "value": "done"},
|
|
]
|
|
|
|
def result_function():
|
|
context.call_count += 1
|
|
index = min(context.call_count - 1, len(context.result_sequence) - 1)
|
|
return context.result_sequence[index]
|
|
|
|
context.test_function = result_function
|
|
|
|
|
|
@given("I have a retry predicate that checks for retry flag")
|
|
def step_setup_retry_predicate(context):
|
|
"""Define predicate used by retry_on_result decorator."""
|
|
|
|
def predicate(result):
|
|
return isinstance(result, dict) and bool(result.get("retry"))
|
|
|
|
context.retry_predicate = predicate
|
|
|
|
|
|
@when("I apply retry on result with max {max_attempts:d} attempts")
|
|
def step_apply_retry_on_result(context, max_attempts):
|
|
"""Apply retry_on_result decorator and capture the outcome."""
|
|
decorated_func = retry_on_result(
|
|
context.retry_predicate, max_attempts=max_attempts
|
|
)(context.test_function)
|
|
|
|
try:
|
|
context.retry_on_result_output = decorated_func()
|
|
context.retry_on_result_succeeded = True
|
|
except Exception as exc: # pragma: no cover - defensive safety
|
|
context.retry_on_result_output = exc
|
|
context.retry_on_result_succeeded = False
|
|
|
|
|
|
@then("the decorated function should return the successful payload")
|
|
def step_verify_retry_on_result_payload(context):
|
|
"""Ensure retry_on_result eventually returns the non-retry payload."""
|
|
assert context.retry_on_result_succeeded, (
|
|
f"Retry on result failed: {context.retry_on_result_output}"
|
|
)
|
|
assert isinstance(context.retry_on_result_output, dict)
|
|
assert context.retry_on_result_output.get("retry") is False
|
|
|
|
|
|
# Auto-debug retry tests
|
|
@given("I have a function that fails with errors")
|
|
def step_create_failing_function_with_errors(context):
|
|
"""Create a function that fails with specific errors."""
|
|
context.failure_count = 0
|
|
|
|
async def failing_func():
|
|
context.failure_count += 1
|
|
if context.failure_count <= 2:
|
|
raise ValueError(f"Error {context.failure_count}")
|
|
return {"success": True, "message": "Fixed after debug"}
|
|
|
|
context.test_function = failing_func
|
|
|
|
|
|
@given("I have a debug callback that fixes issues")
|
|
def step_create_debug_callback(context):
|
|
"""Create a debug callback function."""
|
|
|
|
async def debug_callback(error, attempt):
|
|
# Simulate fixing the issue
|
|
if attempt >= 1:
|
|
return {"fixed": True, "message": f"Fixed error: {error}"}
|
|
return {"fixed": False}
|
|
|
|
context.debug_callback = debug_callback
|
|
|
|
|
|
@when("I apply auto-debug retry")
|
|
def step_apply_auto_debug_retry(context):
|
|
"""Apply auto-debug retry pattern."""
|
|
decorated_func = retry_auto_debug(
|
|
max_debug_attempts=3, debug_callback=context.debug_callback
|
|
)(context.test_function)
|
|
|
|
async def run_auto_debug():
|
|
try:
|
|
result = await decorated_func()
|
|
return result, True
|
|
except Exception as e:
|
|
return e, False
|
|
|
|
loop = asyncio.new_event_loop()
|
|
result, succeeded = loop.run_until_complete(run_auto_debug())
|
|
loop.close()
|
|
|
|
context.debug_result = result
|
|
context.debug_succeeded = succeeded
|
|
|
|
|
|
@then("the function should retry with debug fixes")
|
|
def step_verify_debug_retry(context):
|
|
"""Verify function retried with debug fixes."""
|
|
assert context.failure_count > 1, "Should have failed at least once"
|
|
|
|
|
|
@then("eventually succeed after debugging")
|
|
def step_verify_debug_success(context):
|
|
"""Verify function succeeded after debugging."""
|
|
assert context.debug_succeeded, f"Auto-debug failed: {context.debug_result}"
|
|
assert context.debug_result["success"] is True
|
|
|
|
|
|
# Retry categories tests
|
|
@given("I have the retry categories defined")
|
|
def step_verify_retry_categories(context):
|
|
"""Verify retry categories are defined."""
|
|
context.categories = RETRY_CATEGORIES
|
|
assert "network" in context.categories
|
|
assert "provider" in context.categories
|
|
assert "database" in context.categories
|
|
assert "file_operation" in context.categories
|
|
|
|
|
|
@then("network operations should retry {expected:d} times")
|
|
def step_verify_network_retry_count(context, expected):
|
|
"""Verify network retry configuration."""
|
|
assert context.categories["network"]["max_attempts"] == expected
|
|
|
|
|
|
@then("provider operations should retry {expected:d} times")
|
|
def step_verify_provider_retry_count(context, expected):
|
|
"""Verify provider retry configuration."""
|
|
assert context.categories["provider"]["max_attempts"] == expected
|
|
|
|
|
|
@then("database operations should retry {expected:d} times")
|
|
def step_verify_database_retry_count(context, expected):
|
|
"""Verify database retry configuration."""
|
|
assert context.categories["database"]["max_attempts"] == expected
|
|
|
|
|
|
@then("file operations should retry {expected:d} times")
|
|
def step_verify_file_retry_count(context, expected):
|
|
"""Verify file operation retry configuration."""
|
|
assert context.categories["file_operation"]["max_attempts"] == expected
|
|
|
|
|
|
@given("the retry logger rejects keyword arguments")
|
|
def step_retry_logger_rejects_keyword_arguments(context):
|
|
"""Configure a logger that rejects keyword arguments."""
|
|
context.logged_messages = []
|
|
|
|
class KeywordRejectingLogger:
|
|
def __init__(self, messages):
|
|
self.messages = messages
|
|
|
|
def debug(self, message, *args, **kwargs):
|
|
if kwargs:
|
|
raise TypeError("Keyword arguments not supported")
|
|
self.messages.append(message)
|
|
|
|
def info(self, *args, **kwargs):
|
|
return None
|
|
|
|
def warning(self, *args, **kwargs):
|
|
return None
|
|
|
|
def error(self, *args, **kwargs):
|
|
return None
|
|
|
|
context.original_logger = retry_patterns_module.logger
|
|
retry_patterns_module.logger = KeywordRejectingLogger(context.logged_messages)
|
|
|
|
def cleanup_logger():
|
|
retry_patterns_module.logger = context.original_logger
|
|
|
|
context.add_cleanup(cleanup_logger)
|
|
|
|
|
|
@when("I invoke logging callbacks for failed and successful attempts")
|
|
def step_invoke_logging_callbacks(context):
|
|
"""Trigger logging callbacks to exercise fallback paths."""
|
|
failure_state = SimpleNamespace(
|
|
attempt_number=1,
|
|
outcome=SimpleNamespace(
|
|
failed=True,
|
|
exception=lambda: ValueError("simulated failure"),
|
|
),
|
|
next_action=SimpleNamespace(sleep=0.5),
|
|
)
|
|
success_state = SimpleNamespace(
|
|
attempt_number=2,
|
|
outcome=SimpleNamespace(failed=False),
|
|
next_action=None,
|
|
)
|
|
|
|
log_before_retry(failure_state)
|
|
log_after_retry(failure_state)
|
|
log_after_retry(success_state)
|
|
|
|
|
|
@then("the fallback logging messages should be recorded")
|
|
def step_verify_fallback_logging_messages(context):
|
|
"""Verify fallback logging produced the expected messages."""
|
|
expected_messages = {
|
|
"Starting attempt 1",
|
|
"Attempt 1 failed, will retry",
|
|
"Attempt 2 succeeded",
|
|
}
|
|
recorded = set(context.logged_messages)
|
|
missing = expected_messages - recorded
|
|
assert not missing, f"Missing fallback messages: {missing}"
|
|
|
|
|
|
@given("I have a half-open circuit breaker with threshold {threshold:d}")
|
|
def step_half_open_circuit_breaker(context, threshold):
|
|
"""Prepare a circuit breaker in half-open state."""
|
|
context.circuit_breaker = CircuitBreaker(
|
|
failure_threshold=threshold,
|
|
recovery_timeout=0.1,
|
|
expected_exception=Exception,
|
|
)
|
|
context.circuit_breaker.state = CircuitBreakerState.HALF_OPEN
|
|
context.circuit_breaker.success_count_half_open = 0
|
|
context.circuit_breaker.failure_count = 0
|
|
context.circuit_breaker._half_open_permits = threshold
|
|
|
|
|
|
@when("I record a successful half-open call")
|
|
def step_record_half_open_success(context):
|
|
"""Record the first successful call while half-open."""
|
|
context.last_half_open_result = context.circuit_breaker.call(
|
|
lambda: "half-open success"
|
|
)
|
|
|
|
|
|
@when("I record another successful half-open call")
|
|
def step_record_second_half_open_success(context):
|
|
"""Record the second successful call while half-open."""
|
|
context.second_half_open_result = context.circuit_breaker.call(
|
|
lambda: "half-open success"
|
|
)
|
|
|
|
|
|
@then("the circuit breaker should close after consecutive successes")
|
|
def step_verify_circuit_closes_after_successes(context):
|
|
"""Ensure the circuit closes after consecutive successes."""
|
|
assert context.circuit_breaker.state == "closed", "Circuit should be closed"
|
|
assert context.circuit_breaker.success_count_half_open == 0
|
|
assert context.second_half_open_result == "half-open success"
|
|
|
|
|
|
@when("I simulate a half-open failure")
|
|
def step_simulate_half_open_failure(context):
|
|
"""Simulate a failure while half-open to reopen the circuit."""
|
|
context.circuit_breaker.state = CircuitBreakerState.HALF_OPEN
|
|
context.circuit_breaker.success_count_half_open = 0
|
|
context.circuit_breaker._half_open_permits = 1
|
|
|
|
def failing_call():
|
|
raise Exception("half-open failure")
|
|
|
|
try:
|
|
context.circuit_breaker.call(failing_call)
|
|
except Exception:
|
|
context.half_open_failure_caught = True
|
|
else:
|
|
context.half_open_failure_caught = False
|
|
|
|
|
|
@then("the circuit breaker should reopen after half-open failure")
|
|
def step_verify_circuit_reopens_after_failure(context):
|
|
"""Verify the circuit reopens after a half-open failure."""
|
|
assert context.half_open_failure_caught, "Failure should have been raised"
|
|
assert context.circuit_breaker.state == "open"
|
|
assert context.circuit_breaker.success_count_half_open == 0
|
|
|
|
|
|
@given("I have an async circuit breaker with threshold {threshold:d}")
|
|
def step_create_async_circuit_breaker(context, threshold):
|
|
"""Create a circuit breaker for async operations."""
|
|
context.circuit_breaker = CircuitBreaker(
|
|
failure_threshold=threshold,
|
|
recovery_timeout=0.1, # Use shorter timeout — the sleep step waits 0.2s
|
|
expected_exception=Exception,
|
|
)
|
|
context.async_loop = asyncio.new_event_loop()
|
|
context.add_cleanup(context.async_loop.close)
|
|
|
|
|
|
@when("I trigger an async failure on the circuit breaker")
|
|
def step_trigger_async_failure(context):
|
|
"""Trigger an async failure to open the circuit."""
|
|
|
|
async def failing_call():
|
|
raise Exception("async breaker failure")
|
|
|
|
async def run_failure():
|
|
try:
|
|
await context.circuit_breaker.async_call(failing_call)
|
|
except Exception:
|
|
return True
|
|
return False
|
|
|
|
context.async_failure_caught = context.async_loop.run_until_complete(run_failure())
|
|
assert context.async_failure_caught, "Async failure should have been caught"
|
|
assert context.circuit_breaker.state == "open"
|
|
|
|
|
|
@then("the async circuit breaker should raise immediately while open")
|
|
def step_verify_async_open_state(context):
|
|
"""Ensure async calls are blocked while circuit remains open."""
|
|
|
|
async def success_call():
|
|
return "async success"
|
|
|
|
async def run_open_call():
|
|
try:
|
|
await context.circuit_breaker.async_call(success_call)
|
|
except CircuitBreakerOpen:
|
|
return True
|
|
return False
|
|
|
|
context.async_open_blocked = context.async_loop.run_until_complete(run_open_call())
|
|
assert context.async_open_blocked, "Circuit breaker should prevent async call"
|
|
|
|
|
|
@when("I perform two async successful calls after recovery")
|
|
def step_async_success_calls(context):
|
|
"""Execute two successful async calls after recovery."""
|
|
|
|
async def success_call():
|
|
return "async success"
|
|
|
|
async def run_success_calls():
|
|
first = await context.circuit_breaker.async_call(success_call)
|
|
second = await context.circuit_breaker.async_call(success_call)
|
|
return first, second
|
|
|
|
context.async_success_results = context.async_loop.run_until_complete(
|
|
run_success_calls()
|
|
)
|
|
|
|
|
|
@then("the async circuit breaker should close after async successes")
|
|
def step_verify_async_circuit_closed(context):
|
|
"""Verify the circuit closes after successful async calls."""
|
|
assert context.async_success_results == ("async success", "async success")
|
|
assert context.circuit_breaker.state == "closed"
|
|
|
|
|
|
@given('I have a response payload with error "{message}"')
|
|
def step_response_payload_with_error(context, message):
|
|
"""Create a response payload containing an error message."""
|
|
context.result_payload = {"error": message}
|
|
|
|
|
|
@given("I have a response payload with status code {status:d}")
|
|
def step_response_payload_with_status(context, status):
|
|
"""Create a response payload containing a status code."""
|
|
context.result_payload = {"status_code": status}
|
|
|
|
|
|
@given("I have a result payload set to None")
|
|
def step_result_payload_none(context):
|
|
"""Set the result payload to None."""
|
|
context.result_payload = None
|
|
|
|
|
|
@given('I have a result payload set to the string "{value}"')
|
|
def step_result_payload_string(context, value):
|
|
"""Set the result payload to a literal string value."""
|
|
context.result_payload = value
|
|
|
|
|
|
@when("I evaluate the retry decision")
|
|
def step_evaluate_retry_decision(context):
|
|
"""Evaluate whether the result should trigger a retry."""
|
|
context.retry_decision = should_retry_result(context.result_payload)
|
|
|
|
|
|
@then("the retry decision should be True")
|
|
def step_verify_retry_decision_true(context):
|
|
"""Confirm the retry decision is positive."""
|
|
assert context.retry_decision is True
|
|
|
|
|
|
@then("the retry decision should be False")
|
|
def step_verify_retry_decision_false(context):
|
|
"""Confirm the retry decision is negative."""
|
|
assert context.retry_decision is False
|
|
|
|
|
|
@when('I request a retry decorator for "{category}"')
|
|
def step_request_retry_decorator(context, category):
|
|
"""Request a retry decorator for the given category."""
|
|
context.retry_decorator = get_retry_decorator(category)
|
|
context.decorated_value = None
|
|
|
|
|
|
@when('I decorate a function returning "{value}"')
|
|
def step_decorate_function_with_retry(context, value):
|
|
"""Decorate a function and execute it to capture the result."""
|
|
|
|
def sample_function():
|
|
return value
|
|
|
|
decorated_function = context.retry_decorator(sample_function)
|
|
context.decorated_value = value
|
|
context.decorated_result = decorated_function()
|
|
|
|
|
|
@then("the decorated function should execute successfully")
|
|
def step_verify_decorated_function_execution(context):
|
|
"""Ensure the decorated function returns the expected value."""
|
|
assert context.decorated_result == context.decorated_value
|
|
|
|
|
|
@when("I configure retry logging to {level_name} level")
|
|
def step_configure_retry_logging_level(context, level_name):
|
|
"""Configure retry logging and capture existing levels for restoration."""
|
|
level_attr = level_name.upper()
|
|
assert hasattr(logging, level_attr), f"Unknown log level: {level_name}"
|
|
level_value = getattr(logging, level_attr)
|
|
|
|
before_logger = logging.getLogger("tenacity.before")
|
|
after_logger = logging.getLogger("tenacity.after")
|
|
context.original_tenacity_levels = (before_logger.level, after_logger.level)
|
|
|
|
def restore_log_levels(): # pragma: no cover - cleanup path
|
|
before_logger.setLevel(context.original_tenacity_levels[0])
|
|
after_logger.setLevel(context.original_tenacity_levels[1])
|
|
|
|
context.add_cleanup(restore_log_levels)
|
|
configure_retry_logging(level_value)
|
|
context.configured_log_level = level_value
|
|
|
|
|
|
@then("the tenacity loggers should be set to {level_name}")
|
|
def step_verify_tenacity_log_levels(context, level_name):
|
|
"""Verify tenacity loggers were set to the expected log level."""
|
|
expected_level = getattr(logging, level_name.upper())
|
|
before_level = logging.getLogger("tenacity.before").level
|
|
after_level = logging.getLogger("tenacity.after").level
|
|
assert before_level == expected_level
|
|
assert after_level == expected_level
|
|
assert context.configured_log_level == expected_level
|
|
|
|
|
|
@given("I have a function that returns persistent error payloads")
|
|
def step_function_with_persistent_errors(context):
|
|
"""Create a function that always returns error payloads."""
|
|
context.persistent_error_count = 0
|
|
|
|
async def persistent_error_function():
|
|
context.persistent_error_count += 1
|
|
return {"error": f"Persistent failure {context.persistent_error_count}"}
|
|
|
|
context.test_function = persistent_error_function
|
|
|
|
|
|
@given("I have a debug callback that never fixes issues")
|
|
def step_debug_callback_never_fixes(context):
|
|
"""Create a debug callback that never fixes the issue."""
|
|
|
|
async def never_fix(_error, _attempt):
|
|
return {"fixed": False}
|
|
|
|
context.debug_callback = never_fix
|
|
|
|
|
|
@given("I have a debug callback that raises errors")
|
|
def step_debug_callback_raises_errors(context):
|
|
"""Create a debug callback that raises when invoked."""
|
|
context.debug_callback_failure_count = 0
|
|
|
|
async def raising_callback(_error, _attempt):
|
|
context.debug_callback_failure_count += 1
|
|
raise RuntimeError("debug callback failure")
|
|
|
|
context.debug_callback = raising_callback
|
|
|
|
|
|
@when("I apply auto-debug retry expecting failure")
|
|
def step_apply_auto_debug_retry_failure(context):
|
|
"""Run auto-debug retry expecting it to raise the last error message."""
|
|
decorated_func = retry_auto_debug(
|
|
max_debug_attempts=3, debug_callback=context.debug_callback
|
|
)(context.test_function)
|
|
|
|
async def run_auto_debug():
|
|
try:
|
|
result = await decorated_func()
|
|
return result, True
|
|
except Exception as exc: # pragma: no cover - defensive branch
|
|
return exc, False
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
result, succeeded = loop.run_until_complete(run_auto_debug())
|
|
finally:
|
|
loop.close()
|
|
|
|
context.debug_result = result
|
|
context.debug_succeeded = succeeded
|
|
context.expected_last_error = f"Persistent failure {context.persistent_error_count}"
|
|
|
|
|
|
@then("the auto-debug retry should raise the last error message")
|
|
def step_verify_auto_debug_raises_last_error(context):
|
|
"""Ensure the auto-debug retry raises the final error string."""
|
|
assert not context.debug_succeeded, "Auto-debug retry unexpectedly succeeded"
|
|
assert isinstance(context.debug_result, Exception)
|
|
assert str(context.debug_result) == context.expected_last_error
|
|
|
|
|
|
@then("the auto-debug retry should raise the debug callback failure")
|
|
def step_verify_auto_debug_raises_callback_failure(context):
|
|
"""Ensure the debug callback failure bubble is surfaced."""
|
|
assert not context.debug_succeeded, "Auto-debug retry unexpectedly succeeded"
|
|
assert isinstance(context.debug_result, Exception)
|
|
assert str(context.debug_result) == "debug callback failure"
|
|
|
|
|
|
@then("the debug callback failures should be counted")
|
|
def step_verify_debug_callback_failures(context):
|
|
"""Ensure the debug callback failure path was exercised."""
|
|
failure_count = getattr(context, "debug_callback_failure_count", 0)
|
|
assert failure_count >= 1, "Expected debug callback to fail at least once"
|