Resilience verification suite
Run sixteen failure scenarios against a real graph server with fixture workers and a local mock model endpoint. They cover backend errors/stalls, capacity, worker crashes, cancellation and timeout. No external provider or credential is needed; communication stays on loopback. These replace the older issue-316 resilience checks.
Capabilities
| Capability | Kind | Signature | Module | Summary |
|---|---|---|---|---|
crash | skill | (message: str = 'boom') -> dict | examples/resilience/agent_flaky.py | Always raise; the traverser fails with ``RuntimeError`` and this message. |
maybe_fail | skill | (fail_rate: float = 0.5) -> dict | examples/resilience/agent_flaky.py | Fail with the given probability (0.0 never, 1.0 always); the failure is terminal, not retried. |
ping | skill | () -> dict | examples/resilience/agent_flaky.py | Instant answer; submitted while the worker is down it waits in the queue until a worker returns. |
timeout_then_fail | skill | (delay_seconds: float = 60) -> dict | examples/resilience/agent_flaky.py | Hang for a long time, then raise; the 5-second budget ends it first (``handler_timeout``). |
add | skill | (a: int = 0, b: int = 0) -> dict | examples/resilience/agent_healthy.py | Deterministic, instant arithmetic. |
ask | skill | (prompt: str = 'ping') -> dict | examples/resilience/agent_healthy.py | One model call through the configured provider. |
echo | skill | (message: str = 'hello') -> dict | examples/resilience/agent_healthy.py | Instantly return the input; verifies that basic execution works. |
bounded_task | skill | (duration_seconds: float = 30) -> dict | examples/resilience/agent_slow.py | Blocks past its 3-second budget on purpose; the worker ends it with ``handler_timeout``. |
slow_task | skill | (duration_seconds: float = 30, slice_seconds: float = 0.1) -> dict | examples/resilience/agent_slow.py | Wait for ``duration_seconds`` (stopping early on cancel) and report the actual time taken. |
Commands
python -c 'import asyncio; from atfgraph import Client, TraversalError
python -m atfgraph.worker --module examples.resilience.agent_flaky:app
python -m atfgraph.worker --module examples.resilience.agent_healthy:app
python -m atfgraph.worker --module examples.resilience.agent_slow:app
python -m examples.resilience.mock_llm_server --port 14000
python -m examples.resilience.run_checks
python -m examples.resilience.run_checks # temporary daemon + mock + 3 workers, then teardown
python -m examples.resilience.run_checks --server http://127.0.0.1:18880 --mock-url http://127.0.0.1:14000
python -m examples.resilience.run_checks --server http://127.0.0.1:18880 --mock-url http://127.0.0.1:14000 --no-workers
Files
examples/resilience/README.mdexamples/resilience/__init__.pyexamples/resilience/agent_flaky.pyexamples/resilience/agent_healthy.pyexamples/resilience/agent_slow.pyexamples/resilience/mock_llm_server.pyexamples/resilience/run_checks.py
README
Run sixteen failure scenarios against a real graph server with fixture workers and a local mock model endpoint. They cover backend errors/stalls, capacity, worker crashes, cancellation and timeout. No external provider or credential is needed; communication stays on loopback. These replace the older issue-316 resilience checks.
python -m examples.resilience.run_checks # temporary daemon + mock + 3 workers, then teardown
PASS basic execution
PASS model backend healthy
PASS backend failure detection: status=failed attempts=2 of 2
PASS failure classification: code=retryable message='model backend failed on attempt 2: model endpoint returned HTTP 500 ...'
PASS backend recovery
PASS concurrency limits: peak running=3 (capacity 3), queued observed=True, all completed=True
PASS node visibility
PASS worker kill / restart: while dead: status stayed 'queued' ...; after restart: the same queued run succeeded
PASS event stream: 3 events: ['submitted', 'attempt.started', 'succeeded']
PASS backend freeze (issue #316): status=failed code=handler_timeout after 5.4s
PASS post-recovery execution
PASS crash visibility: code=RuntimeError message='Agent crashed: test crash'
PASS error details in records
PASS client timeout: WaitTimeout after 2 s, then cancel -> cancelled
PASS budget enforcement: status=failed code=handler_timeout after 4.2s
PASS worker death mid-run: status=failed code=interrupted ...
passed 16, failed 0
What is checked, and which vNext mechanism carries it
| # | Check | Original mechanism | vNext mechanism |
|---|---|---|---|
| 1 | Basic execution | sync execute | client.traverse("test-healthy.echo") |
| 2 | Model backend healthy | GET /llm/health | mock /health 200 and test-healthy.ask answers on attempt 1 |
| 3 | Backend failure detection | circuit opens after N failed probes | ask re-raises the provider error as RetryableError; max_attempts=2 exhausts and the run is failed |
| 4 | Failure classification | error_category: llm_unavailable | error.code == "retryable" with the backend's message and the attempt count |
| 5 | Backend recovery | circuit closes | mock recovered; ask succeeds |
| 6 | Concurrency limits | 429 above MAX_CONCURRENT_PER_AGENT | Agent(capacity=3): at most three attempts run; the rest stay queued on the server |
| 7 | Node visibility | /nodes/summary, /nodes/:id/status | GET /api/v2/agents lists the labels; a probe call confirms a worker serves them |
| 8 | Worker kill / restart | agent_unreachable, recovery after restart | the run submitted while the worker is dead stays queued; the restarted worker completes that same run |
| 9 | Event stream | SSE log stream | client.events(id) streams submitted, attempt.started, terminal |
| 10 | Backend freeze (#316) | probes hang, circuit opens | ask against the frozen mock is ended by its 4 s timeout_seconds budget (handler_timeout) |
| 11 | Post-recovery execution | unfreeze; ask succeeds | |
| 12 | Crash visibility | error in response | test-flaky.crash fails with code=RuntimeError and its message |
| 13 | Client timeout | curl --max-time | handle.result(timeout=2) raises WaitTimeout; cancel() ends the run cancelled |
| 14 | Error details in records | GET /executions/:id | GET /api/v2/traversers/{id} keeps status=failed and the same error |
| 15 | Budget enforcement | stale reaper | bounded_task (timeout_seconds=3) ends with handler_timeout; a vanished worker would give the server's own timed_out |
| 16 | Worker death mid-run | stale reaper | SIGKILL during an attempt: the lease expires and the run fails interrupted ("no retries remain") |
The runner also points the daemon's model-health monitor at the mock endpoint and prints its startup confirmation. Current servers expose model-health state through /api/v2/model-health; these scenarios primarily check worker retries and timeout outcomes rather than proving every monitor/admission behavior.
Pieces
| File | Purpose |
|---|---|
agent_healthy.py | echo, add (instant), ask (one model call; max_attempts=2, timeout_seconds=4) |
agent_slow.py | capacity=3; slow_task (cancel-aware wait), bounded_task (3 s budget) |
agent_flaky.py | maybe_fail, crash, timeout_then_fail (5 s budget), ping |
mock_llm_server.py | OpenAI-compatible /v1/chat/completions plus /health, /state, /freeze, /unfreeze, /error, /recover |
run_checks.py | starts what is missing, runs the checks, prints PASS/FAIL, exits non-zero on failure |
Manual use
python -m examples.resilience.mock_llm_server --port 14000
export ATFGRAPH_MODEL_URL=http://127.0.0.1:14000/v1 ATFGRAPH_MODEL_NAME=mock-model
target/debug/atfgraphd
python -m atfgraph.worker --module examples.resilience.agent_healthy:app
python -m atfgraph.worker --module examples.resilience.agent_slow:app
python -m atfgraph.worker --module examples.resilience.agent_flaky:app
# Freeze the backend (the #316 scenario), watch a run hit its budget, thaw it:
curl -X POST http://127.0.0.1:14000/freeze
python -c 'import asyncio; from atfgraph import Client, TraversalError
async def m():
async with Client("http://127.0.0.1:18880") as c:
try: print(await c.traverse("test-healthy.ask", {"prompt": "hi"}, timeout=60))
except TraversalError as e: print(e.status, e.code, e.error)
asyncio.run(m())'
curl -X POST http://127.0.0.1:14000/unfreeze
# Run the checks against what you started (skips the two kill/restart checks):
python -m examples.resilience.run_checks --server http://127.0.0.1:18880 --mock-url http://127.0.0.1:14000 --no-workers
Differences from the original, and open gaps
- Worker capacity queues excess work rather than applying the parent's concurrency rejection behavior. Other configured rate/access rules can still refuse admission. Caller limits are separate.
- Inspect
/api/v2/workersfor the current fleet, and use a real capability call to verify service. Registration alone does not show a worker can execute work. - Work without a serving worker remains queued until a worker returns or the run is cancelled; its execution deadline starts at the first claim.
- Errors use vNext envelopes such as
retryable,handler_timeout,timed_out,interruptedandcancelled. - The formerly blocking flaky task now awaits, and the Python runner replaces the old shell script.
- The migrated-operations suite exercises these checks with a real local daemon. It does not verify live providers or every model-health circuit setting.