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
14 changes: 12 additions & 2 deletions src/otari/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"

Expand Down
46 changes: 46 additions & 0 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"