Skip to content
Closed
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
32 changes: 19 additions & 13 deletions agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -845,19 +845,25 @@ def _extract_status_code(error: Exception) -> Optional[int]:


def _extract_error_body(error: Exception) -> dict:
"""Extract the structured error body from an SDK exception."""
body = getattr(error, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(error, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
"""Walk the error chain to find a structured SDK error body."""
current = error
for _ in range(5): # Max depth to prevent infinite loops
body = getattr(current, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(current, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
if cause is None or cause is current:
break
current = cause
return {}


Expand Down
38 changes: 38 additions & 0 deletions tests/agent/test_error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ def test_from_body_attr(self):
e = MockAPIError("fail", body={"error": {"message": "bad"}})
assert _extract_error_body(e) == {"error": {"message": "bad"}}

def test_from_cause_chain(self):
inner = MockAPIError("inner", body={"error": {"message": "nested"}})
outer = Exception("outer")
outer.__cause__ = inner

assert _extract_error_body(outer) == {"error": {"message": "nested"}}

def test_empty_when_no_body(self):
assert _extract_error_body(Exception("generic")) == {}

Expand Down Expand Up @@ -748,6 +755,37 @@ def test_body_message_enrichment(self):
# "try again" is only in body, not in str(e)
assert result.reason == FailoverReason.rate_limit

def test_wrapped_402_uses_nested_body_for_transient_limit(self):
"""Wrapped SDK errors should keep the nested body used for 402 disambiguation."""
inner = MockAPIError(
"Usage limit",
status_code=402,
body={"error": {"message": "Usage limit reached, try again in 5 minutes"}},
)
outer = Exception("Usage limit")
outer.__cause__ = inner

result = classify_api_error(outer)

assert result.status_code == 402
assert result.reason == FailoverReason.rate_limit
assert result.retryable is True

def test_wrapped_402_billing_without_transient_signal(self):
inner = MockAPIError(
"Payment required",
status_code=402,
body={"error": {"message": "Your credit balance is too low"}},
)
outer = Exception("outer")
outer.__cause__ = inner

result = classify_api_error(outer)

assert result.status_code == 402
assert result.reason == FailoverReason.billing
assert result.retryable is False

def test_disconnect_pattern_ordering(self):
"""Disconnect + large session must beat generic transport catch."""
class FakeRemoteProtocol(Exception):
Expand Down
Loading