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

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's 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 (e.g. checking for "context length" to trigger a retry-with-truncation) silently breaks on this shape.

Why this is worth fixing now

Both sibling SDKs already handle this correctly, so this brings Python in line with a decision that's already been made twice:

Python was the one SDK still missing it.

The fix

Mirrors the TS detailFromObject() precedence exactly: if the resolved detail value is itself a dict, pull its message key; if that's absent or non-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

What I ran

Wrote the tests first and confirmed RED 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

Applied the fix, same file went GREEN:

6 passed, 30 deselected

Then reverted the source fix (kept the tests) to confirm the tests actually catch the regression — same two tests failed with the identical dict-repr output — before restoring the fix.

Full unit suite after restoring the fix:

144 passed in 0.34s

Also ran ruff check (clean) and mypy (clean) on the touched files.

One more thing

The same gap exists in otari-sdk-go's errors.go errorDetail() (around line 283-303) — it tries to unmarshal the "error" value into a string, fails silently on the nested object, and falls through to returning the raw JSON bytes. Happy to file that as a separate PR if maintainers want it — didn't want to bundle an unrelated repo's fix into this one.

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