From 21ff3762565fec249095b6eee60ba78d8efaa352 Mon Sep 17 00:00:00 2001 From: mitnick Date: Fri, 26 Jun 2026 01:45:20 +0000 Subject: [PATCH] fix(interface): exit cleanly on unhandled LLM BadRequestError (#510) When run_cli/run_tui raise an unhandled exception (e.g. openai.BadRequestError from a provider 400), main() re-raised it, surfacing a raw traceback and a PyInstaller crash dump in the binary release after the CLI/TUI had already rendered a user-friendly error panel. Exit with status 1 instead so the finally-block cleanup still runs while suppressing the noisy traceback. Co-Authored-By: Claude Opus 4.8 --- strix/interface/main.py | 7 ++++- tests/test_main_exit.py | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/test_main_exit.py diff --git a/strix/interface/main.py b/strix/interface/main.py index 4eae0527..29aba82b 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -310,6 +310,7 @@ def _positive_budget(value: str) -> float: except ValueError as exc: raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc import math + if not math.isfinite(budget) or budget <= 0: raise argparse.ArgumentTypeError("must be a finite number greater than 0") return budget @@ -824,7 +825,11 @@ def main() -> None: exit_reason = "error" posthog.error("unhandled_exception", str(e)) scarf.error("unhandled_exception", str(e)) - raise + # The CLI/TUI layer already rendered a user-friendly error panel for + # unhandled provider errors (e.g. openai.BadRequestError). Re-raising + # here surfaces a raw traceback (and a PyInstaller crash dump in the + # binary release), so exit cleanly with a non-zero status instead. + sys.exit(1) finally: report_state = get_global_report_state() if report_state: diff --git a/tests/test_main_exit.py b/tests/test_main_exit.py new file mode 100644 index 00000000..70f81386 --- /dev/null +++ b/tests/test_main_exit.py @@ -0,0 +1,70 @@ +"""Tests for clean process exit on unhandled provider errors in ``main``.""" + +import importlib +from argparse import Namespace +from collections.abc import Iterator +from unittest.mock import MagicMock + +import httpx +import openai +import pytest + + +# ``strix.interface.__init__`` rebinds the ``main`` attribute to the function, +# shadowing the submodule, so import the module object explicitly. +main_module = importlib.import_module("strix.interface.main") + + +def _build_args() -> Namespace: + return Namespace( + config=None, + resume="test-run", + run_name=None, + targets_info=[], + instruction=None, + scan_mode="default", + non_interactive=True, + ) + + +def _bad_request_error() -> openai.BadRequestError: + response = httpx.Response(400, request=httpx.Request("POST", "http://test")) + return openai.BadRequestError(message="bad request", response=response, body=None) + + +@pytest.fixture +def _patched_main(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + settings = MagicMock() + monkeypatch.setattr(main_module, "parse_arguments", _build_args) + monkeypatch.setattr(main_module, "check_docker_installed", lambda: None) + monkeypatch.setattr(main_module, "pull_docker_image", lambda: None) + monkeypatch.setattr(main_module, "validate_environment", lambda: None) + monkeypatch.setattr(main_module, "persist_current", lambda: None) + monkeypatch.setattr(main_module, "load_settings", lambda: settings) + monkeypatch.setattr(main_module, "get_global_report_state", lambda: None) + monkeypatch.setattr(main_module, "posthog", MagicMock()) + monkeypatch.setattr(main_module, "scarf", MagicMock()) + + # The first ``asyncio.run`` call is the LLM warm-up (let it pass); the + # second drives the scan and is where an unhandled provider error surfaces. + state = {"calls": 0} + + def fake_run(coro: object) -> None: + if hasattr(coro, "close"): + coro.close() # never execute the coroutine body + state["calls"] += 1 + if state["calls"] == 1: + return + raise _bad_request_error() + + monkeypatch.setattr(main_module.asyncio, "run", fake_run) + yield + + +def test_bad_request_error_exits_cleanly(_patched_main: None) -> None: + """An unhandled ``BadRequestError`` must exit(1), not re-raise.""" + with pytest.raises(SystemExit) as exc_info: + main_module.main() + + assert exc_info.value.code == 1 + assert not isinstance(exc_info.value, openai.BadRequestError)