From a357a571356b155a79828520360abc96aaa32eec Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Fri, 15 May 2026 09:45:31 +0000 Subject: [PATCH] fix(spend-logs): preserve error_message on ProxyException failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StandardLoggingPayloadSetup.get_error_information` used `str(original_exception)` to populate the human-readable error message stored in `spend_logs.metadata.error_information.error_message`. `ProxyException` (litellm/proxy/_types.py:3453) sets `self.message` in its constructor but does NOT call `super().__init__(message)` and does NOT define `__str__`. As a result, `str(ProxyException(...))` returns the empty string, and every auth/budget/quota rejection was landing in spend_logs with `error_message=""` despite a fully populated traceback. Operator impact: dashboard "LLM Failure" rows became untriageable — the only way to tell a 401 from a 429 was to manually unpack the traceback JSON via psql. Burst failure patterns (e.g. a UI session polling with a stale token) produced 20-30 indistinguishable `error_code=401` rows per second. Fix: prefer the `.message` attribute (set by ProxyException and every litellm.exceptions.* class) over `str(exc)`. The `str(exc)` fallback is retained for non-litellm exception types, preserving prior behavior. Test plan: - 2 new unit tests in tests/test_litellm/litellm_core_utils/ test_litellm_logging.py: * test_get_error_information_prefers_message_attribute_over_str * test_get_error_information_falls_back_to_str_when_no_message_attr - Existing test_get_error_information_error_code_priority still passes - End-to-end verified: bad-key 401 now stores full "Authentication Error, Invalid proxy server token passed..." message in spend_logs.metadata.error_information.error_message --- litellm/litellm_core_utils/litellm_logging.py | 16 ++++-- .../test_litellm_logging.py | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index fd14f55add3f..4901e271c972 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5135,15 +5135,25 @@ def get_error_information( tb_lines[:MAXIMUM_TRACEBACK_LINES_TO_LOG] ) # Limit to first 100 lines - # Get additional error details - error_message = str(original_exception) + # Get additional error details. + # Prefer the `.message` attribute (set by ProxyException and every + # litellm.exceptions.* class) over str(exc). ProxyException does not + # call super().__init__() nor define __str__, so str() on it returns + # an empty string — which used to silently strip the human-readable + # message from spend_logs.metadata.error_information and made + # dashboard "LLM Failure" rows untriagable. See e2e/cases/11. + message_attr = getattr(original_exception, "message", None) + if message_attr: + error_message = str(message_attr) + else: + error_message = str(original_exception) if original_exception else "" return StandardLoggingPayloadErrorInformation( error_code=error_status, error_class=error_class, llm_provider=_llm_provider_in_exception, traceback=traceback_info, - error_message=error_message if original_exception else "", + error_message=error_message, ) @staticmethod diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index c3849e5869ad..4832874382fa 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1897,6 +1897,60 @@ def __init__(self, message): assert result["error_class"] == "NoCodeException" +def test_get_error_information_prefers_message_attribute_over_str(): + """ + Regression for empty-error_message-in-spend-logs. + + ProxyException sets `self.message` but does NOT call + `super().__init__(message)` nor define `__str__`, so `str(exc)` + returns the empty string. Before the fix, get_error_information + used `str(original_exception)` and silently stripped the + human-readable message from spend_logs.metadata.error_information, + making dashboard "LLM Failure" rows un-triagable. + + Asserts the `.message` attribute is consulted first. + """ + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + # Simulate a ProxyException-shaped exception: .message set, but + # super().__init__() NOT called and no __str__ override. + class ProxyExceptionLike(Exception): + def __init__(self, message, code): + self.message = str(message) + self.code = str(code) + # NOTE: deliberately NOT calling super().__init__(message) + + msg = "Authentication Error, Invalid proxy server token passed. key=..." + exc = ProxyExceptionLike(message=msg, code=401) + + # Sanity check: this exception type's str() really is empty + assert str(exc) == "", ( + "Test premise broken — bare-base Exception now returns message; " + "review whether ProxyException fix landed at the class level instead" + ) + + result = StandardLoggingPayloadSetup.get_error_information(exc) + assert ( + result["error_message"] == msg + ), f"expected message from .message attribute, got {result['error_message']!r}" + assert result["error_code"] == "401" + assert result["error_class"] == "ProxyExceptionLike" + + +def test_get_error_information_falls_back_to_str_when_no_message_attr(): + """ + Plain Exception (no `.message` attr) must still produce a useful + error_message via str(exc), preserving prior behavior for + non-litellm exception types. + """ + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + exc = ValueError("boom") + result = StandardLoggingPayloadSetup.get_error_information(exc) + assert result["error_message"] == "boom" + assert result["error_class"] == "ValueError" + + # ────────────────────────────────────────────────────────────────────── # Tests for _get_assembled_streaming_response non-streaming early return # ──────────────────────────────────────────────────────────────────────