Skip to content

fix: unwrap nested OpenAI-style error envelope in extract_detail - #42

Open
shoemoney wants to merge 1 commit into
mozilla-ai:mainfrom
shoemoney:fix/nested-openai-error-envelope
Open

fix: unwrap nested OpenAI-style error envelope in extract_detail#42
shoemoney wants to merge 1 commit into
mozilla-ai:mainfrom
shoemoney:fix/nested-openai-error-envelope

Conversation

@shoemoney

@shoemoney shoemoney commented Aug 23, 2026

Copy link
Copy Markdown

What

extract_detail() in src/otari/_base.py pulls the gateway's detail/message/error key out of the error body, but never unwrapped the error value when it is itself a dict. The gateway is OpenAI-compatible, so upstream provider errors arrive as {"error": {"message": "..."}}. That nested dict fell through to str(detail), which stringifies it as a Python repr instead of the actual message.

body = {"error": {"message": "context length exceeded"}}

before: "[gateway] {'message': 'context length exceeded'}"
after:  "[gateway] context length exceeded"

Anything that logs, displays, or string-matches the error message (for example checking for "context length" to trigger a retry-with-truncation) silently breaks on this shape.

Both sibling SDKs already handle it. otari-sdk-ts fixed it in mapError.ts detailFromObject() (#41, closing issue #40, "Error messages are [object Object] for OpenAI-style error bodies"). otari-sdk-rust's core.rs extract_detail() documents both shapes it recognizes: the FastAPI/gateway {"detail": "..."} shape and the OpenAI-style {"error": {"message": "..."}} / {"error": "..."} shapes. Python was the one SDK still missing it.

The fix

Mirrors the TS detailFromObject() precedence: if the resolved detail value is a dict, pull its message key. If that is absent or not a string, fall back to json.dumps(detail) instead of str(detail), so the fallback is valid JSON rather than a Python repr.

if isinstance(detail, dict):
    nested = detail.get("message")
    if isinstance(nested, str):
        return nested
if detail is not None:
    return json.dumps(detail)

Tests

Added TestExtractDetailOpenAIEnvelope to tests/unit/test_errors.py, mirroring the TS test cases in client.test.ts ("extracts a useful message from error envelopes"):

  • nested {"error": {"message": "..."}} -> unwraps to the message
  • regression: FastAPI {"detail": "..."} shape unchanged
  • regression: flat {"error": "..."} string unchanged
  • regression: top-level {"message": "..."} unchanged
  • nested {"error": {"code": 400}} with no message -> valid JSON fallback, not a Python repr
  • non-JSON body returned verbatim

Wrote the tests first and confirmed they fail before touching the source:

tests/unit/test_errors.py::TestExtractDetailOpenAIEnvelope::test_nested_openai_error_object_unwraps_to_message FAILED
tests/unit/test_errors.py::TestExtractDetailOpenAIEnvelope::test_fastapi_detail_shape_unchanged PASSED
tests/unit/test_errors.py::TestExtractDetailOpenAIEnvelope::test_flat_error_string_unchanged PASSED
tests/unit/test_errors.py::TestExtractDetailOpenAIEnvelope::test_top_level_message_unchanged PASSED
tests/unit/test_errors.py::TestExtractDetailOpenAIEnvelope::test_nested_error_object_without_message_falls_back_to_json FAILED
tests/unit/test_errors.py::TestExtractDetailOpenAIEnvelope::test_non_json_body_returned_verbatim PASSED
2 failed, 4 passed, 30 deselected

With the fix applied:

6 passed, 30 deselected

Reverting the source fix (tests kept) reproduced the same two failures with the identical dict-repr output, then the fix was restored. Full unit suite after that:

144 passed in 0.34s

ruff check and mypy are clean on the touched files.

Note

The same gap exists in otari-sdk-go's errors.go errorDetail() (around lines 283-303): it tries to unmarshal the "error" value into a string, fails silently on the nested object, and returns the raw JSON bytes. Happy to file that as a separate PR if wanted; it is not bundled here since it is a different repo.

The gateway is OpenAI-compatible, so upstream provider errors arrive as
{"error": {"message": "..."}}. extract_detail() pulled the "error" key but
never unwrapped the nested dict, so callers got a Python repr string like
"{'message': 'context length exceeded'}" instead of the actual message.

Mirrors the fix already shipped in the sibling SDKs: otari-sdk-ts's
detailFromObject() (PR mozilla-ai#41, closing mozilla-ai#40) and otari-sdk-rust's
extract_detail(), which already documents this exact shape. Brings Python
in line with both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant