From e7c013494d705867eae87c193664add553cf928b Mon Sep 17 00:00:00 2001 From: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:55:04 +0800 Subject: [PATCH] fix(agent): preserve nested API error bodies --- agent/error_classifier.py | 32 +++++++++++++++++----------- tests/agent/test_error_classifier.py | 27 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 2412098cbae..a54738befb7 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -1317,19 +1317,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 + """Extract the structured error body from an SDK exception or its cause chain.""" + current = error + for _ in range(5): # Match _extract_status_code() traversal depth. + 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 e90d86885fd..5d72bc2f142 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -127,6 +127,18 @@ class TestExtractErrorBody: e = MockAPIError("fail", body={"error": {"message": "bad"}}) assert _extract_error_body(e) == {"error": {"message": "bad"}} + def test_from_cause_chain_body_attr(self): + inner = MockAPIError( + "inner", + status_code=402, + body={"error": {"message": "Usage limit reached, try again in 5 minutes"}}, + ) + outer = Exception("outer") + outer.__cause__ = inner + assert _extract_error_body(outer) == { + "error": {"message": "Usage limit reached, try again in 5 minutes"}, + } + def test_empty_when_no_body(self): assert _extract_error_body(Exception("generic")) == {} @@ -300,6 +312,21 @@ class TestClassifyApiError: assert result.retryable is False assert result.should_fallback is True + def test_wrapped_402_uses_nested_body_message(self): + inner = MockAPIError( + "inner", + status_code=402, + body={"error": {"message": "Usage limit reached, try again in 5 minutes"}}, + ) + outer = Exception("outer") + outer.__cause__ = inner + + result = classify_api_error(outer) + + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + assert result.message == "Usage limit reached, try again in 5 minutes" + # ── Rate limit ── def test_429_rate_limit(self):