Skip to content

Commit e5244df

Browse files
biefanromanlutz
andauthored
FIX: Preserve text errors for initializer 403 responses (#2094)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com>
1 parent a149484 commit e5244df

2 files changed

Lines changed: 47 additions & 19 deletions

File tree

pyrit/cli/api_client.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async def register_initializer_async(self, *, name: str, script_content: str) ->
153153
json={"name": name, "script_content": script_content},
154154
)
155155
if resp.status_code == 403:
156-
detail = resp.json().get("detail", "Custom initializer operations are disabled on the server.")
156+
detail = self._response_detail(resp) or "Custom initializer operations are disabled on the server."
157157
raise ServerNotAvailableError(detail)
158158
self._raise_for_status(resp)
159159
return resp.json()
@@ -308,6 +308,39 @@ async def _get_json_async(self, *, path: str, params: dict[str, Any] | None = No
308308
self._raise_for_status(resp)
309309
return resp.json()
310310

311+
@staticmethod
312+
def _response_detail(resp: Any) -> str | None:
313+
"""
314+
Extract a user-facing error detail from a response body.
315+
316+
Prefer FastAPI-style JSON ``detail`` values, then fall back to a plain
317+
text response body. Non-string mock/proxy attributes are ignored so
318+
callers can still use their default error messages.
319+
320+
Returns:
321+
str | None: Extracted detail text, or ``None`` if the body has no
322+
usable error detail.
323+
"""
324+
try:
325+
payload = resp.json()
326+
except Exception:
327+
payload = None
328+
if isinstance(payload, dict):
329+
detail_value = payload.get("detail")
330+
if isinstance(detail_value, str) and detail_value.strip():
331+
return detail_value
332+
if detail_value is not None:
333+
return str(detail_value)
334+
335+
text = getattr(resp, "text", "")
336+
if isinstance(text, bytes):
337+
text = text.decode(errors="replace")
338+
if isinstance(text, str):
339+
text = text.strip()
340+
if text:
341+
return text
342+
return None
343+
311344
@staticmethod
312345
def _raise_for_status(resp: Any) -> None:
313346
"""
@@ -327,22 +360,7 @@ def _raise_for_status(resp: Any) -> None:
327360
try:
328361
resp.raise_for_status()
329362
except httpx.HTTPStatusError as exc:
330-
detail: str | None = None
331-
try:
332-
payload = resp.json()
333-
except Exception:
334-
payload = None
335-
if isinstance(payload, dict):
336-
detail_value = payload.get("detail")
337-
if isinstance(detail_value, str) and detail_value.strip():
338-
detail = detail_value
339-
elif detail_value is not None:
340-
detail = str(detail_value)
341-
if detail is None:
342-
text = getattr(resp, "text", "") or ""
343-
text = text.strip()
344-
if text:
345-
detail = text
363+
detail = PyRITApiClient._response_detail(resp)
346364
if detail is None:
347365
raise
348366
message = f"{exc}: {detail}"

tests/unit/cli/test_api_client.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ def client(mock_httpx_client):
3131
return c
3232

3333

34-
def _make_response(*, status_code=200, json_data=None):
34+
def _make_response(*, status_code=200, json_data=None, text_data=""):
3535
resp = MagicMock()
3636
resp.status_code = status_code
37-
resp.json = MagicMock(return_value=json_data or {})
37+
resp.json = MagicMock(return_value={} if json_data is None else json_data)
38+
resp.text = text_data
3839
resp.raise_for_status = MagicMock()
3940
return resp
4041

@@ -185,6 +186,15 @@ async def test_register_initializer_async_raises_on_403(client, mock_httpx_clien
185186
await client.register_initializer_async(name="x", script_content="...")
186187

187188

189+
async def test_register_initializer_async_raises_on_403_with_plain_text_body(client, mock_httpx_client):
190+
resp = _make_response(status_code=403, text_data="Forbidden by proxy")
191+
resp.json.side_effect = ValueError("not json")
192+
mock_httpx_client.post.return_value = resp
193+
194+
with pytest.raises(ServerNotAvailableError, match="Forbidden by proxy"):
195+
await client.register_initializer_async(name="x", script_content="...")
196+
197+
188198
async def test_register_initializer_async_raises_on_500(client, mock_httpx_client):
189199
resp = _make_response(status_code=500)
190200
resp.raise_for_status.side_effect = httpx.HTTPStatusError("500", request=MagicMock(), response=resp)

0 commit comments

Comments
 (0)