From 748162d667cb4db73d35313f6f439b3398439a49 Mon Sep 17 00:00:00 2001 From: biefan <70761325+biefan@users.noreply.github.com> Date: Sun, 28 Jun 2026 04:52:24 +0000 Subject: [PATCH] Handle text initializer 403 errors --- pyrit/cli/api_client.py | 52 +++++++++++++++++++++---------- tests/unit/cli/test_api_client.py | 14 +++++++-- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/pyrit/cli/api_client.py b/pyrit/cli/api_client.py index 937dfad9a4..67216f73c6 100644 --- a/pyrit/cli/api_client.py +++ b/pyrit/cli/api_client.py @@ -153,7 +153,7 @@ async def register_initializer_async(self, *, name: str, script_content: str) -> json={"name": name, "script_content": script_content}, ) if resp.status_code == 403: - detail = resp.json().get("detail", "Custom initializer operations are disabled on the server.") + detail = self._response_detail(resp) or "Custom initializer operations are disabled on the server." raise ServerNotAvailableError(detail) self._raise_for_status(resp) return resp.json() @@ -308,6 +308,39 @@ async def _get_json_async(self, *, path: str, params: dict[str, Any] | None = No self._raise_for_status(resp) return resp.json() + @staticmethod + def _response_detail(resp: Any) -> str | None: + """ + Extract a user-facing error detail from a response body. + + Prefer FastAPI-style JSON ``detail`` values, then fall back to a plain + text response body. Non-string mock/proxy attributes are ignored so + callers can still use their default error messages. + + Returns: + str | None: Extracted detail text, or ``None`` if the body has no + usable error detail. + """ + try: + payload = resp.json() + except Exception: + payload = None + if isinstance(payload, dict): + detail_value = payload.get("detail") + if isinstance(detail_value, str) and detail_value.strip(): + return detail_value + if detail_value is not None: + return str(detail_value) + + text = getattr(resp, "text", "") + if isinstance(text, bytes): + text = text.decode(errors="replace") + if isinstance(text, str): + text = text.strip() + if text: + return text + return None + @staticmethod def _raise_for_status(resp: Any) -> None: """ @@ -327,22 +360,7 @@ def _raise_for_status(resp: Any) -> None: try: resp.raise_for_status() except httpx.HTTPStatusError as exc: - detail: str | None = None - try: - payload = resp.json() - except Exception: - payload = None - if isinstance(payload, dict): - detail_value = payload.get("detail") - if isinstance(detail_value, str) and detail_value.strip(): - detail = detail_value - elif detail_value is not None: - detail = str(detail_value) - if detail is None: - text = getattr(resp, "text", "") or "" - text = text.strip() - if text: - detail = text + detail = PyRITApiClient._response_detail(resp) if detail is None: raise message = f"{exc}: {detail}" diff --git a/tests/unit/cli/test_api_client.py b/tests/unit/cli/test_api_client.py index 95c232273e..2ee8a9ed31 100644 --- a/tests/unit/cli/test_api_client.py +++ b/tests/unit/cli/test_api_client.py @@ -31,10 +31,11 @@ def client(mock_httpx_client): return c -def _make_response(*, status_code=200, json_data=None): +def _make_response(*, status_code=200, json_data=None, text_data=""): resp = MagicMock() resp.status_code = status_code - resp.json = MagicMock(return_value=json_data or {}) + resp.json = MagicMock(return_value={} if json_data is None else json_data) + resp.text = text_data resp.raise_for_status = MagicMock() return resp @@ -185,6 +186,15 @@ async def test_register_initializer_async_raises_on_403(client, mock_httpx_clien await client.register_initializer_async(name="x", script_content="...") +async def test_register_initializer_async_raises_on_403_with_plain_text_body(client, mock_httpx_client): + resp = _make_response(status_code=403, text_data="Forbidden by proxy") + resp.json.side_effect = ValueError("not json") + mock_httpx_client.post.return_value = resp + + with pytest.raises(ServerNotAvailableError, match="Forbidden by proxy"): + await client.register_initializer_async(name="x", script_content="...") + + async def test_register_initializer_async_raises_on_500(client, mock_httpx_client): resp = _make_response(status_code=500) resp.raise_for_status.side_effect = httpx.HTTPStatusError("500", request=MagicMock(), response=resp)