From 672698c5ebb1c03df6bf01a0887b73a3d2a1554f Mon Sep 17 00:00:00 2001 From: jungraekim-gif Date: Mon, 8 Jun 2026 13:36:23 +0800 Subject: [PATCH] fix(router): stamp actual retry count on exception when retry_policy sets retries to 0 When retry_policy (e.g. TimeoutErrorRetries=0) prevents retries, the router raises immediately without entering the retry loop, so the post-loop setattr that records actual retry counts was never reached. Meanwhile the @client decorator in utils.py unconditionally injected litellm.num_retries (e.g. 3) onto the exception, causing misleading "LiteLLM Retried: 3 times" in error messages even though zero retries occurred. Changes: - router.py: stamp max_retries=0 / num_retries=0 on the exception in the else-raise path so error messages always reflect the real retry count - utils.py: skip the num_retries injection for router/proxy calls; the router already manages retry counts itself via the post-loop setattr - exceptions.py: change `if self.num_retries` / `if self.max_retries` to `is not None` checks so a count of 0 is displayed rather than suppressed Co-Authored-By: Claude Sonnet 4.6 --- litellm/exceptions.py | 128 +++++++++++++++++++++--------------------- litellm/router.py | 6 ++ litellm/utils.py | 16 +++++- 3 files changed, 83 insertions(+), 67 deletions(-) diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 51810c5643f..c1bd70cfe25 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -60,17 +60,17 @@ class AuthenticationError(openai.AuthenticationError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -106,17 +106,17 @@ class NotFoundError(openai.NotFoundError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -157,17 +157,17 @@ class BadRequestError(openai.BadRequestError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -220,17 +220,17 @@ class UnprocessableEntityError(openai.UnprocessableEntityError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -266,17 +266,17 @@ class Timeout(openai.APITimeoutError): # type: ignore # custom function to convert to str def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -305,17 +305,17 @@ class PermissionDeniedError(openai.PermissionDeniedError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -357,17 +357,17 @@ class RateLimitError(openai.RateLimitError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -399,17 +399,17 @@ class ContextWindowExceededError(BadRequestError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -442,17 +442,17 @@ class RejectedRequestError(BadRequestError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -495,9 +495,9 @@ class ContentPolicyViolationError(BadRequestError): # type: ignore Transform the error to a string """ _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -537,17 +537,17 @@ class ServiceUnavailableError(openai.APIStatusError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -587,17 +587,17 @@ class BadGatewayError(openai.APIStatusError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -637,17 +637,17 @@ class InternalServerError(openai.InternalServerError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -678,17 +678,17 @@ class APIError(openai.APIError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -717,17 +717,17 @@ class APIConnectionError(openai.APIConnectionError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -755,17 +755,17 @@ class APIResponseValidationError(openai.APIResponseValidationError): # type: ig def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message def __repr__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" return _message @@ -1005,9 +1005,9 @@ class MidStreamFallbackError(ServiceUnavailableError): # type: ignore def __str__(self): _message = self.message - if self.num_retries: + if self.num_retries is not None: _message += f" LiteLLM Retried: {self.num_retries} times" - if self.max_retries: + if self.max_retries is not None: _message += f", LiteLLM Max Retries: {self.max_retries}" if self.original_exception: _message += f" Original exception: {type(self.original_exception).__name__}: {str(self.original_exception)}" diff --git a/litellm/router.py b/litellm/router.py index b275c264ebc..27d3e2152aa 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -5792,6 +5792,12 @@ class Router: if num_retries > 0: kwargs = self.log_retry(kwargs=kwargs, e=original_exception) else: + # No retries will occur; stamp the exception with the actual counts + # (0) before raising so error messages reflect reality instead of + # carrying a stale value injected by the @client decorator. + if type(original_exception) in litellm.LITELLM_EXCEPTION_TYPES: + setattr(original_exception, "max_retries", 0) + setattr(original_exception, "num_retries", 0) raise verbose_router_logger.debug( diff --git a/litellm/utils.py b/litellm/utils.py index e1ad1db63ef..f62d5ef030b 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2088,9 +2088,19 @@ def client(original_function): # noqa: PLR0915 except Exception: pass - setattr( - e, "num_retries", num_retries - ) ## IMPORTANT: returns the deployment's num_retries to the router + # Only set num_retries on the exception for non-router calls. + # When the router is in use, it manages retry counts itself and stamps + # the actual attempted count via setattr after the retry loop completes. + # Injecting litellm.num_retries here for router calls causes misleading + # "LiteLLM Retried: N times" in error messages even when zero retries + # occurred (e.g. TimeoutErrorRetries=0 in retry_policy). + _is_router_or_proxy_call = "model_group" in ( + kwargs.get("metadata") or {} + ) + if not _is_router_or_proxy_call: + setattr( + e, "num_retries", num_retries + ) ## returns the deployment's num_retries for non-router retry logic timeout = _get_wrapper_timeout(kwargs=kwargs, exception=e) setattr(e, "timeout", timeout)