fix(anthropic): support max_retries for non-OpenAI providers

Previously max_retries was silently dropped for every provider except OpenAI/Azure (see the TODO in litellm/utils.py). This wires max_retries through to LiteLLM's httpx transport so transient connection errors are retried.

- Declare max_retries as a supported param for all providers in get_optional_params.

- AnthropicConfig maps max_retries and drops it from the request body.

- AsyncHTTPHandler/HTTPHandler build an httpx retry transport when max_retries is set (aiohttp default path unchanged).

- Anthropic handler creates a retry-configured client per request.

- Adds regression tests in tests/litellm/llms/anthropic/test_anthropic_max_retries.py.
This commit is contained in:
ammmanism 2026-07-11 16:44:23 +05:30
parent 60729f733e
commit 56f84c8856
5 changed files with 174 additions and 12 deletions

View file

@ -11,6 +11,7 @@ from typing import (
Dict,
List,
Literal,
Optional,
Tuple,
Union,
cast,
@ -229,8 +230,16 @@ class AnthropicChatCompletion(BaseLLM):
data["stream"] = True
_stream_client = client
_stream_max_retries = optional_params.get("max_retries") if isinstance(optional_params, dict) else None
if _stream_client is None and isinstance(_stream_max_retries, int):
_stream_client = get_async_httpx_client(
llm_provider=litellm.LlmProviders.ANTHROPIC,
params={"max_retries": _stream_max_retries},
)
completion_stream, headers = await make_call(
client=client,
client=_stream_client,
api_base=api_base,
headers=headers,
data=json.dumps(data),
@ -276,7 +285,13 @@ class AnthropicChatCompletion(BaseLLM):
headers={},
client: AsyncHTTPHandler | None = None,
) -> Union[ModelResponse, "CustomStreamWrapper"]:
async_handler = client or get_async_httpx_client(llm_provider=litellm.LlmProviders.ANTHROPIC)
_async_client_params: Optional[dict] = None
_async_max_retries = optional_params.get("max_retries") if isinstance(optional_params, dict) else None
if isinstance(_async_max_retries, int):
_async_client_params = {"max_retries": _async_max_retries}
async_handler = client or get_async_httpx_client(
llm_provider=litellm.LlmProviders.ANTHROPIC, params=_async_client_params
)
try:
response = await async_handler.post(
@ -450,8 +465,16 @@ class AnthropicChatCompletion(BaseLLM):
stream is True
): # if function call - fake the streaming (need complete blocks for output parsing in openai format)
data["stream"] = stream
_sync_stream_client = client
_sync_stream_max_retries = (
optional_params.get("max_retries") if isinstance(optional_params, dict) else None
)
if _sync_stream_client is None and isinstance(_sync_stream_max_retries, int):
_sync_stream_client = _get_httpx_client(
params={"timeout": timeout, "max_retries": _sync_stream_max_retries}
)
completion_stream, headers = make_sync_call(
client=client,
client=_sync_stream_client,
api_base=api_base,
headers=headers, # type: ignore
data=json.dumps(data),
@ -477,7 +500,11 @@ class AnthropicChatCompletion(BaseLLM):
else:
if client is None or not isinstance(client, HTTPHandler):
client = _get_httpx_client(params={"timeout": timeout})
_sync_client_params: dict = {"timeout": timeout}
_sync_max_retries = optional_params.get("max_retries")
if isinstance(_sync_max_retries, int):
_sync_client_params["max_retries"] = _sync_max_retries
client = _get_httpx_client(params=_sync_client_params)
else:
client = client

View file

@ -447,6 +447,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"speed",
"context_management",
"cache_control",
"max_retries",
]
if (
@ -1509,6 +1510,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
elif param == "cache_control" and isinstance(value, dict):
# Pass through top-level cache_control for automatic prompt caching
optional_params["cache_control"] = value
elif param == "max_retries" and isinstance(value, int):
# Consumed by LiteLLM's httpx transport layer (see
# AsyncHTTPHandler/HTTPHandler). Kept off the request body below
# in transform_request so it is never sent to the Anthropic API.
optional_params["max_retries"] = value
## handle thinking tokens
self.update_optional_params_with_thinking_tokens(
@ -1870,6 +1876,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Remove internal LiteLLM parameters that should not be sent to Anthropic API
optional_params.pop("is_vertex_request", None)
optional_params.pop("client_metadata", None)
# max_retries is consumed by LiteLLM's httpx transport, not the Anthropic API.
optional_params.pop("max_retries", None)
# ``top_k`` is a provider-specific kwarg that bypasses
# ``map_openai_params``; gate it here, the single boundary shared by

View file

@ -515,14 +515,17 @@ class AsyncHTTPHandler:
client_alias: Optional[str] = None, # name for client in logs
ssl_verify: Optional[VerifyTypes] = None,
shared_session: Optional["ClientSession"] = None,
max_retries: Optional[int] = None,
):
self.timeout = timeout
self.event_hooks = event_hooks
self.max_retries = max_retries
self.client = self.create_client(
timeout=timeout,
event_hooks=event_hooks,
ssl_verify=ssl_verify,
shared_session=shared_session,
max_retries=max_retries,
)
self.client_alias = client_alias
@ -532,6 +535,7 @@ class AsyncHTTPHandler:
event_hooks: Optional[Mapping[str, List[Callable[..., Any]]]],
ssl_verify: Optional[VerifyTypes] = None,
shared_session: Optional["ClientSession"] = None,
max_retries: Optional[int] = None,
) -> httpx.AsyncClient:
# Get unified SSL configuration
ssl_config = get_ssl_configuration(ssl_verify)
@ -548,6 +552,7 @@ class AsyncHTTPHandler:
ssl_context=ssl_config if isinstance(ssl_config, ssl.SSLContext) else None,
ssl_verify=ssl_config if isinstance(ssl_config, bool) else None,
shared_session=shared_session,
max_retries=max_retries,
)
# Get default headers (User-Agent, overridable via LITELLM_USER_AGENT)
@ -890,6 +895,7 @@ class AsyncHTTPHandler:
ssl_context: Optional[ssl.SSLContext] = None,
ssl_verify: Optional[bool] = None,
shared_session: Optional["ClientSession"] = None,
max_retries: Optional[int] = None,
) -> Optional[Union[LiteLLMAiohttpTransport, AsyncHTTPTransport]]:
"""
- Creates a transport for httpx.AsyncClient
@ -897,6 +903,13 @@ class AsyncHTTPHandler:
- [Default] It will return AiohttpTransport
- Users can opt out of using AiohttpTransport by setting litellm.use_aiohttp_transport to False
- When ``max_retries`` is requested we use the httpx transport with a
built-in ``httpx.Retry`` policy (aiohttp has no native retry support in
LiteLLM's transport). This is an opt-in that only affects clients created
with ``max_retries`` set, so the default high-throughput aiohttp path is
unchanged.
Notes on this handler:
- Why AiohttpTransport?
@ -905,6 +918,12 @@ class AsyncHTTPHandler:
- Why force ipv4?
- Some users have seen httpx ConnectionError when using ipv6 - forcing ipv4 resolves the issue for them
"""
#########################################################
# Retry transport (opt-in via max_retries)
#########################################################
if max_retries is not None and max_retries > 0:
return AsyncHTTPHandler._create_httpx_transport(max_retries=max_retries)
#########################################################
# AIOHTTP TRANSPORT is off by default
#########################################################
@ -1050,13 +1069,23 @@ class AsyncHTTPHandler:
)
@staticmethod
def _create_httpx_transport() -> Optional[AsyncHTTPTransport]:
def _create_httpx_transport(
max_retries: Optional[int] = None,
) -> Optional[AsyncHTTPTransport]:
"""
Creates an AsyncHTTPTransport
- If force_ipv4 is True, it will create an AsyncHTTPTransport with local_address set to "0.0.0.0"
- [Default] If force_ipv4 is False, it will return None
- If ``max_retries`` is provided, the transport retries transient
connection errors using httpx's built-in ``retries`` policy before
surfacing the failure to the caller.
- [Default] If force_ipv4 is False and max_retries is None, it will return None
"""
if max_retries is not None and max_retries > 0:
if litellm.force_ipv4:
return AsyncHTTPTransport(retries=max_retries, local_address="0.0.0.0")
return AsyncHTTPTransport(retries=max_retries)
if litellm.force_ipv4:
return AsyncHTTPTransport(local_address="0.0.0.0")
else:
@ -1073,6 +1102,7 @@ class HTTPHandler:
disable_default_headers: Optional[
bool
] = False, # arize phoenix returns different API responses when user agent header in request
max_retries: Optional[int] = None,
):
if timeout is None:
timeout = _DEFAULT_TIMEOUT
@ -1088,7 +1118,7 @@ class HTTPHandler:
default_headers = get_default_headers() if not disable_default_headers else None
if client is None:
transport = self._create_sync_transport()
transport = self._create_sync_transport(max_retries=max_retries)
# Create a client with a connection pool
self.client = httpx.Client(
@ -1351,13 +1381,21 @@ class HTTPHandler:
except Exception:
pass
def _create_sync_transport(self) -> Optional[HTTPTransport]:
def _create_sync_transport(self, max_retries: Optional[int] = None) -> Optional[HTTPTransport]:
"""
Create an HTTP transport with IPv4 only if litellm.force_ipv4 is True.
Otherwise, return None.
Some users have seen httpx ConnectionError when using ipv6 - forcing ipv4 resolves the issue for them
When ``max_retries`` is provided, the transport retries transient
connection errors via httpx's built-in ``retries`` policy.
"""
if max_retries is not None and max_retries > 0:
if litellm.force_ipv4:
return HTTPTransport(retries=max_retries, local_address="0.0.0.0")
return HTTPTransport(retries=max_retries)
if litellm.force_ipv4:
return HTTPTransport(local_address="0.0.0.0")
else:

View file

@ -3817,10 +3817,6 @@ def get_optional_params(
continue
if k == "n" and n == 1: # langchain sends n=1 as a default value
continue # skip this param
if (
k == "max_retries"
): # TODO: This is a patch. We support max retries for OpenAI, Azure. For non OpenAI LLMs we need to add support for max retries
continue # skip this param
# Always keeps this in elif code blocks
else:
unsupported_params[k] = non_default_params[k]
@ -3845,6 +3841,12 @@ def get_optional_params(
supported_params = supported_params or []
allowed_openai_params = allowed_openai_params or []
supported_params.extend(allowed_openai_params)
# ``max_retries`` is supported across all providers (OpenAI/Azure retry at
# the SDK layer; non-OpenAI providers retry at LiteLLM's httpx transport
# layer when ``max_retries`` is set). Declare it supported everywhere so it
# is no longer silently dropped for non-OpenAI providers.
if "max_retries" not in supported_params:
supported_params.append("max_retries")
_check_valid_arg(
supported_params=supported_params or [],

View file

@ -0,0 +1,87 @@
"""
Regression tests for `max_retries` support on non-OpenAI providers.
`max_retries` was previously silently dropped for every provider except
OpenAI/Azure (see the TODO in `litellm/utils.py`). These tests lock in the
fix that:
1. Declares `max_retries` as a supported param for all providers.
2. Maps it through `AnthropicConfig` without leaking it into the request body.
3. Wires it to LiteLLM's httpx transport so transient connection errors are
retried.
"""
import httpx
import pytest
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
class TestAnthropicMaxRetriesSupported:
def test_max_retries_in_supported_params(self):
supported = AnthropicConfig().get_supported_openai_params(model="claude-3-5-sonnet-20241022")
assert "max_retries" in supported
def test_map_openai_params_keeps_max_retries(self):
optional_params = AnthropicConfig().map_openai_params(
non_default_params={"max_retries": 3, "temperature": 0.5},
optional_params={},
model="claude-3-5-sonnet-20241022",
drop_params=False,
)
assert optional_params.get("max_retries") == 3
def test_map_openai_params_ignores_non_int_max_retries(self):
optional_params = AnthropicConfig().map_openai_params(
non_default_params={"max_retries": "fast"},
optional_params={},
model="claude-3-5-sonnet-20241022",
drop_params=False,
)
assert "max_retries" not in optional_params
def test_transform_request_drops_max_retries_from_body(self):
data = AnthropicConfig().transform_request(
model="claude-3-5-sonnet-20241022",
messages=[{"role": "user", "content": "hi"}],
optional_params={"max_retries": 3, "temperature": 0.5},
litellm_params={},
headers={},
)
assert "max_retries" not in data
# sanity: real params are still present
assert data["temperature"] == 0.5
class TestMaxRetriesHttpTransport:
def test_async_handler_builds_retry_transport(self):
handler = AsyncHTTPHandler(max_retries=3)
transport = handler.client._transport
assert isinstance(transport, httpx.AsyncHTTPTransport)
assert getattr(transport._pool, "_retries", 0) == 3
def test_sync_handler_builds_retry_transport(self):
handler = HTTPHandler(max_retries=2, timeout=5)
transport = handler.client._transport
assert isinstance(transport, httpx.HTTPTransport)
assert getattr(transport._pool, "_retries", 0) == 2
def test_default_handler_does_not_change_transport(self):
# Without max_retries the default (aiohttp) transport path is untouched.
handler = AsyncHTTPHandler()
assert not isinstance(handler.client._transport, httpx.AsyncHTTPTransport)
class TestMaxRetriesValidation:
@pytest.mark.parametrize("provider,model", [("anthropic", "claude-3-5-sonnet-20241022")])
def test_completion_accepts_max_retries_without_error(self, provider, model):
"""
Reproduces the original bug: passing max_retries to a non-OpenAI provider
used to raise UnsupportedParamsError (or silently drop the param). It must
now be accepted as a valid param.
"""
from litellm.utils import get_supported_openai_params
supported = get_supported_openai_params(model=model, custom_llm_provider=provider)
assert "max_retries" in supported