fix(anthropic): stop SDK retries from resending a rejected block or clearing the global num_retries

A transient 429 or 500 on a /v1/messages turn that carries a replayed encrypted
block was retried by an SDK call's num_retries loop, which sent the block again
until every retry was rejected. On async calls the loop then raised the first
transient error, so the turn failed instead of being sent again without the
block. The retry loop now stops at that rejection and passes it on, so the turn
is sent again once without encrypted blocks.

A rejected first try on a sync call also cleared the process-wide
litellm.num_retries, so later calls in the process were no longer retried. That
setting now stays as configured.
This commit is contained in:
mateo-berri 2026-11-07 10:22:13 -08:00
parent 433a76f754
commit b277d687b5
4 changed files with 155 additions and 16 deletions

View file

@ -27,6 +27,7 @@ from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, Union, cast, get_args
from litellm._internal_context import is_resent_failure
from litellm._logging import redact_string
from litellm._uuid import uuid
@ -6242,6 +6243,10 @@ def completion(
)
def _is_not_resent_failure(error: BaseException) -> bool:
return not (isinstance(error, Exception) and is_resent_failure(error))
def completion_with_retries(*args, **kwargs):
"""
Executes a litellm.completion() with 3 retries
@ -6261,10 +6266,15 @@ def completion_with_retries(*args, **kwargs):
retryer = tenacity.Retrying(
wait=tenacity.wait_exponential(multiplier=1, max=10),
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
else:
retryer = tenacity.Retrying(stop=tenacity.stop_after_attempt(num_retries), reraise=True)
retryer = tenacity.Retrying(
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
return retryer(original_function, *args, **kwargs)
@ -6287,10 +6297,15 @@ async def acompletion_with_retries(*args, **kwargs):
retryer = tenacity.AsyncRetrying(
wait=tenacity.wait_exponential(multiplier=1, max=10),
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
else:
retryer = tenacity.AsyncRetrying(stop=tenacity.stop_after_attempt(num_retries), reraise=True)
retryer = tenacity.AsyncRetrying(
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
return await retryer(original_function, *args, **kwargs)
@ -6315,10 +6330,15 @@ def responses_with_retries(*args, **kwargs):
retryer = tenacity.Retrying(
wait=tenacity.wait_exponential(multiplier=1, max=10),
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
else:
retryer = tenacity.Retrying(stop=tenacity.stop_after_attempt(num_retries), reraise=True)
retryer = tenacity.Retrying(
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
return retryer(original_function, *args, **kwargs)
@ -6342,10 +6362,15 @@ async def aresponses_with_retries(*args, **kwargs):
retryer = tenacity.AsyncRetrying(
wait=tenacity.wait_exponential(multiplier=1, max=10),
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
else:
retryer = tenacity.AsyncRetrying(stop=tenacity.stop_after_attempt(num_retries), reraise=True)
retryer = tenacity.AsyncRetrying(
stop=tenacity.stop_after_attempt(num_retries),
retry=tenacity.retry_if_exception(_is_not_resent_failure),
reraise=True,
)
return await retryer(original_function, *args, **kwargs)

View file

@ -1980,7 +1980,8 @@ def client(original_function):
retry_policy=kwargs.get("retry_policy"),
)
kwargs["retry_policy"] = reset_retry_policy() # prevent infinite loops
litellm.num_retries = None # set retries to None to prevent infinite loops
if not is_resent_failure(e):
litellm.num_retries = None # set retries to None to prevent infinite loops
context_window_fallback_dict: Final = kwargs.get("context_window_fallback_dict", {})
is_completion_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=False)
@ -2015,7 +2016,8 @@ def client(original_function):
retry_policy=kwargs.get("retry_policy"),
)
kwargs["retry_policy"] = reset_retry_policy() # prevent infinite loops
litellm.num_retries = None # set retries to None to prevent infinite loops
if not is_resent_failure(e):
litellm.num_retries = None # set retries to None to prevent infinite loops
is_responses_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=False)
if (
@ -2314,8 +2316,9 @@ def client(original_function):
elif isinstance(e, openai.APIError): # generic api error
kwargs["retry_strategy"] = "constant_retry"
result = await litellm.acompletion_with_retries(*args, **kwargs)
except Exception:
pass
except Exception as retry_error:
if is_resent_failure(retry_error):
raise
else:
return result
elif (
@ -2348,8 +2351,9 @@ def client(original_function):
elif isinstance(e, openai.APIError): # generic api error
kwargs["retry_strategy"] = "constant_retry"
result = await litellm.aresponses_with_retries(*args, **kwargs)
except Exception:
pass
except Exception as retry_error:
if is_resent_failure(retry_error):
raise
else:
return result

View file

@ -5608,9 +5608,10 @@ async def test_async_messages_with_num_retries_send_the_rejected_reasoning_only_
@pytest.mark.usefixtures("_chat_bridge_httpx")
def test_sync_messages_with_num_retries_send_the_rejected_reasoning_only_once(
respx_mock: respx.MockRouter,
def test_sync_messages_with_global_num_retries_send_the_rejected_reasoning_once_and_keep_the_setting(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "num_retries", 2)
route: Final = respx_mock.post(url__regex=_RETRY_URL).mock(
side_effect=[_RETRY_REJECTION, httpx.Response(200, json=_RETRY_REPLY)]
)
@ -5623,10 +5624,62 @@ def test_sync_messages_with_num_retries_send_the_rejected_reasoning_only_once(
max_tokens=64,
tools=_RETRY_TOOLS,
messages=_history_with_foreign_reasoning(),
num_retries=2,
)
assert [len(_sent_reasoning_items(call.request)) for call in route.calls] == [1, 0]
assert litellm.num_retries == 2
_SERVER_ERROR: Final = httpx.Response(
500, json={"error": {"message": "The server had an error while processing your request.", "type": "server_error"}}
)
@pytest.mark.asyncio
@pytest.mark.usefixtures("_chat_bridge_httpx")
async def test_async_messages_retry_a_server_error_with_the_reasoning_and_drop_it_only_once_rejected(
respx_mock: respx.MockRouter,
) -> None:
route: Final = respx_mock.post(url__regex=_RETRY_URL).mock(
side_effect=[_SERVER_ERROR, _RETRY_REJECTION, httpx.Response(200, json=_RETRY_REPLY)]
)
response: Final = await litellm.anthropic.messages.acreate(
model="azure/gpt-5.4-nano",
api_base=_RETRY_API_BASE,
api_key="fake-key",
api_version="2025-04-01-preview",
max_tokens=64,
tools=_RETRY_TOOLS,
messages=_history_with_foreign_reasoning(),
num_retries=2,
)
assert [len(_sent_reasoning_items(call.request)) for call in route.calls] == [1, 1, 0]
assert [block["text"] for block in response["content"] if block["type"] == "text"] == ["Oslo"]
@pytest.mark.usefixtures("_chat_bridge_httpx")
def test_sync_messages_retry_a_server_error_with_the_reasoning_and_drop_it_only_once_rejected(
respx_mock: respx.MockRouter,
) -> None:
route: Final = respx_mock.post(url__regex=_RETRY_URL).mock(
side_effect=[_SERVER_ERROR, _RETRY_REJECTION, httpx.Response(200, json=_RETRY_REPLY)]
)
response: Final = litellm.anthropic.messages.create(
model="azure/gpt-5.4-nano",
api_base=_RETRY_API_BASE,
api_key="fake-key",
api_version="2025-04-01-preview",
max_tokens=64,
tools=_RETRY_TOOLS,
messages=_history_with_foreign_reasoning(),
num_retries=2,
)
assert [len(_sent_reasoning_items(call.request)) for call in route.calls] == [1, 1, 0]
assert [block["text"] for block in response["content"] if block["type"] == "text"] == ["Oslo"]
class _FailureLog(CustomLogger):

View file

@ -747,6 +747,12 @@ def _replayed_reasoning_counts(upstream: respx.Route) -> list[int]:
def _upstream(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, first: httpx.Response, stream: bool):
return _upstream_after(respx_mock, monkeypatch, (first,), stream)
def _upstream_after(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, failures: tuple[httpx.Response, ...], stream: bool
) -> respx.Route:
monkeypatch.setenv("OPENAI_API_KEY", "sk-replay-test")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
litellm.in_memory_llm_clients_cache.flush_cache()
@ -755,7 +761,7 @@ def _upstream(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, fir
if stream
else httpx.Response(200, json=TEXT_RESPONSE)
)
return respx_mock.post("https://api.openai.com/v1/responses").mock(side_effect=[first, served])
return respx_mock.post("https://api.openai.com/v1/responses").mock(side_effect=[*failures, served])
@pytest.mark.asyncio
@ -817,9 +823,10 @@ async def test_openai_turn_with_num_retries_sends_the_rejected_reasoning_only_on
assert _replayed_reasoning_counts(upstream) == [1, 0]
def test_sync_openai_turn_with_num_retries_sends_the_rejected_reasoning_only_once(
def test_sync_openai_turn_with_global_num_retries_sends_the_rejected_reasoning_once_and_keeps_the_setting(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "num_retries", 2)
upstream: Final = _upstream(respx_mock, monkeypatch, httpx.Response(400, json=REJECTED_REPLAYED_REASONING), False)
litellm.anthropic.messages.create(
@ -827,10 +834,60 @@ def test_sync_openai_turn_with_num_retries_sends_the_rejected_reasoning_only_onc
messages=_history(replayed_reasoning=True),
model="openai/gpt-5.6-luna",
tools=[dict(BASH_TOOL)],
num_retries=2,
)
assert _replayed_reasoning_counts(upstream) == [1, 0]
assert litellm.num_retries == 2
_SERVER_ERROR: Final = {
"error": {"message": "The server had an error while processing your request.", "type": "server_error"}
}
@pytest.mark.asyncio
async def test_openai_turn_retries_a_server_error_with_the_reasoning_and_drops_it_only_once_rejected(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
upstream: Final = _upstream_after(
respx_mock,
monkeypatch,
(httpx.Response(500, json=_SERVER_ERROR), httpx.Response(400, json=REJECTED_REPLAYED_REASONING)),
False,
)
response: Final = await litellm.anthropic.messages.acreate(
max_tokens=64,
messages=_history(replayed_reasoning=True),
model="openai/gpt-5.6-luna",
tools=[dict(BASH_TOOL)],
num_retries=2,
)
assert _replayed_reasoning_counts(upstream) == [1, 1, 0]
assert cast(Mapping[str, object], response)["content"] == [{"type": "text", "text": "done"}]
def test_sync_openai_turn_retries_a_server_error_with_the_reasoning_and_drops_it_only_once_rejected(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
upstream: Final = _upstream_after(
respx_mock,
monkeypatch,
(httpx.Response(500, json=_SERVER_ERROR), httpx.Response(400, json=REJECTED_REPLAYED_REASONING)),
False,
)
response: Final = litellm.anthropic.messages.create(
max_tokens=64,
messages=_history(replayed_reasoning=True),
model="openai/gpt-5.6-luna",
tools=[dict(BASH_TOOL)],
num_retries=2,
)
assert _replayed_reasoning_counts(upstream) == [1, 1, 0]
assert cast(Mapping[str, object], response)["content"] == [{"type": "text", "text": "done"}]
class _FailureLog(CustomLogger):