fix(anthropic): skip the SDK num_retries retry for an attempt that is sent again

This commit is contained in:
mateo-berri 2026-11-07 09:28:59 -08:00
parent c72cb031d8
commit 433a76f754
4 changed files with 90 additions and 5 deletions

View file

@ -118,7 +118,8 @@ def current_service_caller() -> str | None:
@contextmanager
def resent_on(rejection: Callable[[Exception], bool]) -> Generator[None]:
"""An attempt its caller sends again when it fails with ``rejection``. That failure is not the request's
outcome, so nothing logs it and its budget reservation and rate-limit slot stay with the attempt sent again."""
outcome, so it is neither logged as the request's failure nor retried, and its budget reservation and rate-limit
slot stay with the attempt sent again."""
token: Final = _resent_on.set(rejection)
try:
yield

View file

@ -1985,7 +1985,7 @@ def client(original_function):
is_completion_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=False)
if (
num_retries and not is_completion_litellm_router_call
num_retries and not is_completion_litellm_router_call and not is_resent_failure(e)
): # only enter this if call is not from litellm router/proxy. router has it's own logic for retrying
if (
isinstance(e, openai.APIError)
@ -2019,7 +2019,7 @@ def client(original_function):
is_responses_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=False)
if (
num_retries and not is_responses_litellm_router_call
num_retries and not is_responses_litellm_router_call and not is_resent_failure(e)
): # only enter this if call is not from litellm router/proxy. router has it's own logic for retrying
if (
isinstance(e, openai.APIError)
@ -2300,7 +2300,10 @@ def client(original_function):
is_acompletion_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=True)
if (
num_retries and not is_acompletion_litellm_router_call and not isinstance(e, ImportError)
num_retries
and not is_acompletion_litellm_router_call
and not isinstance(e, ImportError)
and not is_resent_failure(e)
): # only enter this if call is not from litellm router/proxy. router has it's own logic for retrying
try:
litellm.num_retries = None # set retries to None to prevent infinite loops
@ -2331,7 +2334,10 @@ def client(original_function):
is_aresponses_litellm_router_call: Final = _is_litellm_router_call(kwargs, is_async=True)
if (
num_retries and not is_aresponses_litellm_router_call and not isinstance(e, ImportError)
num_retries
and not is_aresponses_litellm_router_call
and not isinstance(e, ImportError)
and not is_resent_failure(e)
): # only enter this if call is not from litellm router/proxy. router has it's own logic for retrying
try:
litellm.num_retries = None # set retries to None to prevent infinite loops

View file

@ -5584,6 +5584,51 @@ def test_sync_messages_retry_once_without_reasoning_the_backend_cannot_decrypt(
assert [block["text"] for block in response["content"] if block["type"] == "text"] == ["Oslo"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("_chat_bridge_httpx")
async def test_async_messages_with_num_retries_send_the_rejected_reasoning_only_once(
respx_mock: respx.MockRouter,
) -> None:
route: Final = respx_mock.post(url__regex=_RETRY_URL).mock(
side_effect=[_RETRY_REJECTION, httpx.Response(200, json=_RETRY_REPLY)]
)
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, 0]
@pytest.mark.usefixtures("_chat_bridge_httpx")
def test_sync_messages_with_num_retries_send_the_rejected_reasoning_only_once(
respx_mock: respx.MockRouter,
) -> None:
route: Final = respx_mock.post(url__regex=_RETRY_URL).mock(
side_effect=[_RETRY_REJECTION, httpx.Response(200, json=_RETRY_REPLY)]
)
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, 0]
class _FailureLog(CustomLogger):
def __init__(self) -> None:
super().__init__()

View file

@ -800,6 +800,39 @@ def test_sync_openai_turn_is_sent_again_once_without_replayed_reasoning_the_back
assert cast(Mapping[str, object], response)["content"] == [{"type": "text", "text": "done"}]
@pytest.mark.asyncio
async def test_openai_turn_with_num_retries_sends_the_rejected_reasoning_only_once(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
upstream: Final = _upstream(respx_mock, monkeypatch, httpx.Response(400, json=REJECTED_REPLAYED_REASONING), False)
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, 0]
def test_sync_openai_turn_with_num_retries_sends_the_rejected_reasoning_only_once(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
upstream: Final = _upstream(respx_mock, monkeypatch, httpx.Response(400, json=REJECTED_REPLAYED_REASONING), False)
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, 0]
class _FailureLog(CustomLogger):
def __init__(self) -> None:
super().__init__()