051ee7c290
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 47s
CI / security (pull_request) Successful in 52s
CI / build (pull_request) Successful in 56s
CI / e2e_tests (pull_request) Successful in 5m1s
CI / integration_tests (pull_request) Successful in 5m30s
CI / unit_tests (pull_request) Successful in 5m42s
CI / docker (pull_request) Successful in 58s
CI / coverage (pull_request) Successful in 7m35s
CI / build (push) Successful in 21s
CI / docker (push) Has been skipped
CI / benchmark-regression (pull_request) Failing after 49m24s
CI / lint (push) Successful in 22s
CI / quality (push) Successful in 39s
CI / security (push) Successful in 48s
CI / typecheck (push) Successful in 1m26s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 5m53s
CI / coverage (push) Successful in 9m4s
CI / benchmark-publish (push) Successful in 19m10s
CI / integration_tests (push) Failing after 19m18s
CI / unit_tests (push) Failing after 19m20s
Added 52 new .feature files and corresponding _steps.py files targeting previously uncovered code paths in the following areas: - TUI layer: app, commands, persona (state/schema/registry), widgets, input (shell_exec, reference_parser) - Application services: plan lifecycle/service/executor, session, project, repo indexing, correction, checkpoint, actor, llm_actors, strategy coordinator, resource file watcher, service retry wiring - CLI commands: session, resource, repl, plan, db, automation_profile - Domain models: retry_policy, resource_type, cost_budget, docker_compose_analyzer, detail_level, _sql_string_aware, _postgresql_helpers - Core: circuit_breaker, retry_service_patterns - Infrastructure: repositories, transaction_sandbox, strategy_registry, plugins/loader, container - Config: settings - Agents: plan_generation, context_analysis, auto_debug - A2A: facade All new tests follow the Behave/Gherkin BDD standard. Resolved step definition collisions with unique prefixes. Fixed Alembic fileConfig logger disabling issue (disable_existing_loggers=False). ISSUES CLOSED: #1068
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""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}"
|
|
)
|