diff --git a/src/otari/_base.py b/src/otari/_base.py index af33103..d1b7b0d 100644 --- a/src/otari/_base.py +++ b/src/otari/_base.py @@ -252,7 +252,13 @@ def _url_encode(value: str) -> str: def extract_detail(error: ApiException) -> str: - """Pull the gateway's human-readable detail from an ``ApiException`` body.""" + """Pull the gateway's human-readable detail from an ``ApiException`` body. + + Recognizes the FastAPI/gateway ``{"detail": "..."}`` shape and the + OpenAI-style ``{"error": {"message": "..."}}`` / ``{"error": "..."}`` + shapes, mirroring ``extract_detail`` in the Rust SDK (``core.rs``) and + ``detailFromObject`` in the TS SDK (``mapError.ts``). + """ body = error.body if isinstance(body, (bytes, bytearray)): body = body.decode("utf-8", "replace") @@ -265,8 +271,12 @@ def extract_detail(error: ApiException) -> str: detail = parsed.get("detail") or parsed.get("message") or parsed.get("error") if isinstance(detail, str): return detail + if isinstance(detail, dict): + nested = detail.get("message") + if isinstance(nested, str): + return nested if detail is not None: - return str(detail) + return json.dumps(detail) return body return error.reason or "An error occurred" diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 8336683..fe6266b 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -5,6 +5,10 @@ from __future__ import annotations +import json + +from otari._base import extract_detail +from otari._client.exceptions import ApiException from otari.errors import ( AuthenticationError, BatchNotCompleteError, @@ -175,3 +179,45 @@ def test_str_with_provider_name(self) -> None: provider="anthropic", ) assert str(err) == "[gateway] not supported" + + +class TestExtractDetailOpenAIEnvelope: + """extract_detail must unwrap the OpenAI-style nested ``error`` envelope. + + Mirrors the TS SDK's ``detailFromObject`` (mozilla-ai/otari-sdk-ts, + PR #41 / issue #40) and the Rust SDK's ``extract_detail`` in + ``otari-sdk-rust/src/core.rs``, which both already handle this shape. + """ + + def test_nested_openai_error_object_unwraps_to_message(self) -> None: + body = json.dumps({"error": {"message": "context length exceeded"}}) + err = ApiException(status=400, body=body, reason="Bad Request") + assert extract_detail(err) == "context length exceeded" + + def test_fastapi_detail_shape_unchanged(self) -> None: + body = json.dumps({"detail": "boom"}) + err = ApiException(status=400, body=body, reason="Bad Request") + assert extract_detail(err) == "boom" + + def test_flat_error_string_unchanged(self) -> None: + body = json.dumps({"error": "flat string"}) + err = ApiException(status=400, body=body, reason="Bad Request") + assert extract_detail(err) == "flat string" + + def test_top_level_message_unchanged(self) -> None: + body = json.dumps({"message": "top level message"}) + err = ApiException(status=400, body=body, reason="Bad Request") + assert extract_detail(err) == "top level message" + + def test_nested_error_object_without_message_falls_back_to_json(self) -> None: + body = json.dumps({"error": {"code": 400}}) + err = ApiException(status=400, body=body, reason="Bad Request") + result = extract_detail(err) + # Must be valid JSON round-tripping to the nested object, not a + # Python repr (which would contain single-quoted keys). + assert "'" not in result + assert json.loads(result) == {"code": 400} + + def test_non_json_body_returned_verbatim(self) -> None: + err = ApiException(status=500, body="plain text failure", reason="Server Error") + assert extract_detail(err) == "plain text failure"