atlas-iac/testing/tests/test_hermes_cli_provider_health.py
jenkins 6df688adcc hermes: test quota gating, cooldown recovery, and failover policy
Deterministic coverage for the quota-aware lane: threshold boundaries
(14.9/15/15.1), both-below preference, fetch-failure fail-open, cooldown
elapsed-vs-not hysteresis, quota-reset recovery (never for auth),
explicit fail-closed in both directions, bounded double-failure block,
failure-reason classification, metrics emission, and worker env key
stripping. Based on PR #15 (fix/hermes-result-decomposition-reliability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 20:31:12 -03:00

201 lines
6.7 KiB
Python

"""Lane-side provider health writes, cooldown hysteresis, and recovery."""
from __future__ import annotations
from testing.tests.test_hermes_cli_support import (
Path,
json,
lanes,
pytest,
)
@pytest.fixture
def health_paths(tmp_path: Path, monkeypatch) -> dict[str, Path]:
paths = {
"codex": tmp_path / "health/codex.json",
"claude": tmp_path / "health/claude.json",
}
monkeypatch.setattr(lanes, "PROVIDER_HEALTH_PATHS", paths)
monkeypatch.setattr(lanes, "DATA_ROOT", tmp_path)
return paths
@pytest.mark.parametrize(
("output", "expected"),
[
("You have hit your usage limit for this billing cycle.", "quota"),
("credit balance exhausted", "quota"),
("HTTP 429: too many requests", "rate-limit"),
("overloaded_error (529)", "rate-limit"),
("OAuth token expired; run codex login", "auth"),
("401 Unauthorized", "auth"),
("stream disconnected before completion", "transport"),
],
)
def test_capacity_failures_are_classified_actionably(output: str, expected: str):
assert lanes.classify_capacity_failure(output) == expected
def test_capacity_failure_records_cooldown_with_defaults(health_paths):
until = lanes.record_provider_failure("codex", "quota", now=1000.0)
value = json.loads(health_paths["codex"].read_text())
assert until == 1300.0
assert value["state"] == "capacity-limited"
assert value["authenticated"] is True
assert value["failure_reason"] == "quota"
assert value["failed_at"] == 1000.0
assert value["cooldown_until"] == 1300.0
assert value["source"] == "cli-lane-runner"
def test_auth_failure_uses_longer_cooldown_and_marks_unauthenticated(health_paths):
until = lanes.record_provider_failure("claude", "auth", now=1000.0)
value = json.loads(health_paths["claude"].read_text())
assert until == 4600.0
assert value["state"] == "unavailable"
assert value["authenticated"] is False
def test_cooldowns_are_configurable_through_the_kanban_block(
health_paths, tmp_path: Path
):
(tmp_path / "config.yaml").write_text(
"kanban:\n"
" provider_capacity_cooldown_seconds: 60\n"
" provider_auth_cooldown_seconds: 120\n"
)
assert lanes.record_provider_failure("codex", "rate-limit", now=1000.0) == 1060.0
assert lanes.record_provider_failure("codex", "auth", now=1000.0) == 1120.0
def test_lane_writes_merge_with_broker_health_snapshots(health_paths):
health_paths["codex"].parent.mkdir(parents=True)
health_paths["codex"].write_text(
json.dumps({"transport": "codex-chatgpt-subscription", "state": "available"})
)
lanes.record_provider_failure("codex", "quota", now=1000.0)
value = json.loads(health_paths["codex"].read_text())
assert value["transport"] == "codex-chatgpt-subscription"
assert value["state"] == "capacity-limited"
def test_success_clears_failure_evidence_and_readmits(health_paths):
lanes.record_provider_failure("codex", "quota", now=1000.0)
lanes.record_provider_success("codex")
value = json.loads(health_paths["codex"].read_text())
assert value["state"] == "available"
assert value["authenticated"] is True
for stale in ("failure_reason", "failed_at", "cooldown_until"):
assert stale not in value
assert lanes.fresh_unavailable_provider(now=1001.0) is None
def test_health_write_failures_never_break_the_lane(health_paths, tmp_path: Path):
blocker = tmp_path / "health"
blocker.write_text("a file where the directory must go")
lanes.record_provider_failure("codex", "quota", now=1000.0)
def test_broker_reported_capacity_limited_state_is_excluded(health_paths):
health_paths["codex"].parent.mkdir(parents=True)
health_paths["codex"].write_text('{"state":"capacity-limited"}\n')
now = health_paths["codex"].stat().st_mtime
assert lanes.fresh_unavailable_provider(now=now) == "codex"
def test_cooldown_must_fully_elapse_before_readmission(health_paths):
lanes.record_provider_failure("codex", "quota", now=1000.0)
assert lanes.fresh_unavailable_provider(now=1299.9) == "codex"
assert lanes.fresh_unavailable_provider(now=1300.0) is None
def test_quota_reset_readmits_capacity_limited_provider_early(health_paths):
lanes.record_provider_failure("codex", "quota", now=1000.0)
# Reset before the failure proves nothing; a future reset has not passed.
assert (
lanes.fresh_unavailable_provider(now=1100.0, quota_resets={"codex": 900.0})
== "codex"
)
assert (
lanes.fresh_unavailable_provider(now=1100.0, quota_resets={"codex": 1150.0})
== "codex"
)
# A reset between the failure and now means the window rolled over.
assert (
lanes.fresh_unavailable_provider(now=1200.0, quota_resets={"codex": 1150.0})
is None
)
def test_quota_reset_never_readmits_auth_failures(health_paths):
lanes.record_provider_failure("codex", "auth", now=1000.0)
assert (
lanes.fresh_unavailable_provider(now=1200.0, quota_resets={"codex": 1150.0})
== "codex"
)
def test_both_providers_in_cooldown_defer_to_live_attempts(health_paths):
lanes.record_provider_failure("codex", "quota", now=1000.0)
lanes.record_provider_failure("claude", "quota", now=1000.0)
assert lanes.fresh_unavailable_provider(now=1100.0) is None
def test_vanished_health_file_is_not_excluded(health_paths, monkeypatch):
monkeypatch.setattr(lanes, "load_json", lambda _path: {"state": "unavailable"})
assert lanes.fresh_unavailable_provider(now=1000.0) is None
def test_malformed_cooldown_falls_back_to_freshness_window(health_paths):
health_paths["claude"].parent.mkdir(parents=True)
health_paths["claude"].write_text(
'{"state":"capacity-limited","cooldown_until":true}\n'
)
now = health_paths["claude"].stat().st_mtime
assert lanes.fresh_unavailable_provider(now=now) == "claude"
assert lanes.fresh_unavailable_provider(now=now + 10 * 60) is None
def test_health_number_rejects_booleans_and_strings():
assert lanes._health_number(True) is None
assert lanes._health_number("5") is None
assert lanes._health_number(5) == 5.0
def test_worker_environment_strips_metered_api_keys(monkeypatch):
for name in (
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"OPENAI_API_KEY",
"API_SERVER_KEY",
):
monkeypatch.setenv(name, "must-not-leak")
monkeypatch.setenv("CODEX_HOME", "/runtime-access/codex")
env = lanes._base_env()
for name in (
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"OPENAI_API_KEY",
"API_SERVER_KEY",
):
assert name not in env
assert env["CODEX_HOME"] == "/runtime-access/codex"
assert env["GIT_TERMINAL_PROMPT"] == "0"