From 16a7465b9b513567569ef4cf1a6afbfaf7e21609 Mon Sep 17 00:00:00 2001 From: sgaofen <135070653+sgaofen@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:19:20 -0700 Subject: [PATCH] fix(agent): inspect wrapped API error bodies --- agent/error_classifier.py | 32 +++++++++++++---------- tests/agent/test_error_classifier.py | 38 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 04875b6a5458..63cdd2e9a82b 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -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 {} diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index c8faffb0c6d5..28c350940530 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -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")) == {} @@ -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):