From 777005a42b8e359e62558fa9c6d92eca2cbb541d Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:31:54 -0700 Subject: [PATCH] fix: stop gracefully with resume hint on persistent RateLimitError (#261) (#593) --- strix/core/runner.py | 14 ++++++ tests/test_runner_rate_limit.py | 84 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/test_runner_rate_limit.py diff --git a/strix/core/runner.py b/strix/core/runner.py index 82e653d..90becf8 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any from agents import RunConfig from agents.sandbox import SandboxRunConfig +from openai import RateLimitError from strix.agents.factory import build_strix_agent, make_child_factory from strix.config import load_settings @@ -308,6 +309,19 @@ async def run_strix_scan( with contextlib.suppress(Exception): await coordinator.set_status(root_id, "stopped") return None + except RateLimitError as exc: + logger.warning( + "Scan %s stopped: persistent rate limit from the LLM provider (%s). " + "Resume with 'strix --resume %s' once the limit clears.", + scan_id, + exc, + scan_id, + ) + if root_id is not None: + await coordinator.cancel_descendants(root_id) + with contextlib.suppress(Exception): + await coordinator.set_status(root_id, "stopped") + return None except BaseException: logger.exception("Strix scan %s failed", scan_id) if root_id is not None: diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py new file mode 100644 index 0000000..ed28d25 --- /dev/null +++ b/tests/test_runner_rate_limit.py @@ -0,0 +1,84 @@ +"""Tests for graceful handling of persistent RateLimitError in run_strix_scan.""" + +from __future__ import annotations + +import logging +import types +from typing import Any + +import httpx +import pytest +from openai import RateLimitError + +import strix.tools.notes.tools as notes_tools +import strix.tools.todo.tools as todo_tools +from strix.core import runner +from strix.core.agents import AgentCoordinator + + +def _make_rate_limit_error() -> RateLimitError: + request = httpx.Request("POST", "https://api.openai.com/v1/responses") + response = httpx.Response(status_code=429, request=request) + return RateLimitError("rate limited", response=response, body=None) + + +@pytest.mark.asyncio +async def test_persistent_rate_limit_stops_gracefully( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any, caplog: pytest.LogCaptureFixture +) -> None: + """A persistent RateLimitError stops the scan (root -> 'stopped') without raising.""" + monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path) + monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path) + monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None) + monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None) + + settings = types.SimpleNamespace( + llm=types.SimpleNamespace(model="openai/gpt-4o", reasoning_effort="high") + ) + monkeypatch.setattr(runner, "load_settings", lambda: settings) + monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None) + monkeypatch.setattr( + runner, "uses_chat_completions_tool_schema", lambda _model, _settings: False + ) + + monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _state_dir: None) + monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _state_dir: None) + + async def _create_or_reuse(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + return {"client": object(), "session": object(), "caido_client": None} + + async def _cleanup(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse) + monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup) + + monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task") + monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "") + monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object()) + monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object()) + monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object()) + monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object()) + + async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None: + raise _make_rate_limit_error() + + monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit) + + coordinator = AgentCoordinator() + + with caplog.at_level(logging.WARNING): + result = await runner.run_strix_scan( + scan_config={"targets": [], "scan_mode": "deep"}, + scan_id="scan-test", + image="img", + coordinator=coordinator, + ) + + assert result is None + root_ids = [aid for aid, parent in coordinator.parent_of.items() if parent is None] + assert len(root_ids) == 1 + assert coordinator.statuses[root_ids[0]] == "stopped" + # the resume hint must carry the real scan id, not a literal placeholder + assert "strix --resume scan-test" in caplog.text + assert "" not in caplog.text