Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions pyrit/cli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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}"
Expand Down
14 changes: 12 additions & 2 deletions tests/unit/cli/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down