fix(anthropic): honor messages request timeout (#33418)
Some checks failed
CodSpeed Benchmarks / benchmarks (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

* fix(anthropic): honor messages request timeout

* fix(anthropic): keep client default connect timeout when no timeout configured

* test(anthropic): isolate global request timeout state in messages handler timeout tests

---------

Co-authored-by: Melvin Orichi <melvin.orichisocana@joinhandshake.com>
This commit is contained in:
yucheng-berri 2026-07-15 14:37:48 -07:00 committed by GitHub
parent 24a438adfd
commit 9121ae3024
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 177 additions and 1 deletions

View file

@ -1879,6 +1879,7 @@ class BaseLLMHTTPHandler:
litellm_params: GenericLiteLLMParams,
api_key: Optional[str],
model: str,
timeout: Optional[Union[float, httpx.Timeout]] = None,
) -> httpx.Response:
max_attempts = max(provider_config.max_retry_on_anthropic_messages_http_error, 1)
litellm_params_dict = dict(litellm_params)
@ -1891,6 +1892,7 @@ class BaseLLMHTTPHandler:
data=signed_json_body or json.dumps(request_body),
stream=stream or False,
logging_obj=logging_obj,
timeout=timeout,
)
response.raise_for_status()
return response
@ -1925,6 +1927,32 @@ class BaseLLMHTTPHandler:
raise RuntimeError("unreachable: anthropic messages HTTP retry loop exited without return")
@staticmethod
def _resolve_anthropic_messages_timeout(
litellm_params: GenericLiteLLMParams,
stream: bool,
custom_llm_provider: str,
) -> Optional[Union[float, httpx.Timeout]]:
from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
from litellm.litellm_core_utils.request_timeout_resolver import (
get_configured_request_timeout,
)
from litellm.utils import supports_httpx_timeout
stream_timeout = litellm_params.get("stream_timeout") if stream else None
model_timeout = stream_timeout if stream_timeout is not None else litellm_params.get("timeout")
request_timeout = litellm_params.get("request_timeout")
global_timeout = get_configured_request_timeout()
if model_timeout is None and request_timeout is None and global_timeout is None:
return None
return CompletionTimeout.resolve(
model_timeout,
{"request_timeout": request_timeout},
custom_llm_provider,
global_timeout=global_timeout,
supports_httpx_timeout=supports_httpx_timeout,
)
async def async_anthropic_messages_handler(
self,
model: str,
@ -2075,6 +2103,11 @@ class BaseLLMHTTPHandler:
litellm_params=litellm_params,
api_key=api_key,
model=model,
timeout=self._resolve_anthropic_messages_timeout(
litellm_params=litellm_params,
stream=stream or False,
custom_llm_provider=custom_llm_provider,
),
)
# used for logging + cost tracking

View file

@ -1084,6 +1084,147 @@ def test_sync_delete_responses_sets_json_content_type():
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"litellm_params_kwargs, stream, global_timeout, expected",
[
({"timeout": 12.0}, False, None, 12.0),
({"request_timeout": 30.0}, False, None, 30.0),
({}, False, 1500.0, 1500.0),
({"timeout": 5.0, "stream_timeout": 50.0}, True, None, 50.0),
({"timeout": 5.0, "stream_timeout": 50.0}, False, None, 5.0),
({"timeout": 5.0, "request_timeout": 30.0}, False, None, 5.0),
({}, False, None, None),
({}, True, None, None),
],
)
def test_resolve_anthropic_messages_timeout(
monkeypatch, litellm_params_kwargs, stream, global_timeout, expected
):
from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS
if global_timeout is None:
monkeypatch.setattr(
"litellm.request_timeout",
float(DEFAULT_REQUEST_TIMEOUT_SECONDS),
raising=False,
)
monkeypatch.setattr(
"litellm.request_timeout_explicitly_set",
False,
raising=False,
)
else:
monkeypatch.setattr("litellm.request_timeout", global_timeout, raising=False)
monkeypatch.setattr(
"litellm.request_timeout_explicitly_set", True, raising=False
)
resolved = BaseLLMHTTPHandler._resolve_anthropic_messages_timeout(
litellm_params=GenericLiteLLMParams(**litellm_params_kwargs),
stream=stream,
custom_llm_provider="anthropic",
)
assert resolved == expected
@pytest.mark.asyncio
async def test_async_anthropic_messages_handler_forwards_request_timeout(monkeypatch):
from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "request_timeout", float(DEFAULT_REQUEST_TIMEOUT_SECONDS))
monkeypatch.setattr(litellm, "request_timeout_explicitly_set", False)
handler = BaseLLMHTTPHandler()
mock_config = Mock()
mock_config.validate_anthropic_messages_environment = Mock(
return_value=({"x-api-key": "k"}, "https://api.anthropic.com")
)
mock_config.should_filter_anthropic_beta_headers = Mock(return_value=False)
mock_config.transform_anthropic_messages_request = Mock(
return_value={"model": "claude", "messages": []}
)
mock_config.get_complete_url = Mock(return_value="https://api.anthropic.com/v1/messages")
mock_config.sign_request = Mock(return_value=({"x-api-key": "k"}, None))
mock_config.max_retry_on_anthropic_messages_http_error = 1
expected_response = {"id": "msg_1", "content": []}
mock_config.transform_anthropic_messages_response = Mock(return_value=expected_response)
ok_response = Mock()
ok_response.raise_for_status = Mock(return_value=None)
mock_client = AsyncMock(spec=AsyncHTTPHandler)
mock_client.post = AsyncMock(return_value=ok_response)
logging_obj = Mock()
logging_obj.model_call_details = {}
logging_obj.dynamic_success_callbacks = []
result = await handler.async_anthropic_messages_handler(
model="claude",
messages=[{"role": "user", "content": "hi"}],
anthropic_messages_provider_config=mock_config,
anthropic_messages_optional_request_params={},
custom_llm_provider="anthropic",
litellm_params=GenericLiteLLMParams(request_timeout=0.3),
logging_obj=logging_obj,
client=mock_client,
kwargs={},
)
assert result is expected_response
assert mock_client.post.await_args.kwargs["timeout"] == 0.3
@pytest.mark.asyncio
async def test_async_anthropic_messages_handler_forwards_stream_timeout(monkeypatch):
from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "request_timeout", float(DEFAULT_REQUEST_TIMEOUT_SECONDS))
monkeypatch.setattr(litellm, "request_timeout_explicitly_set", False)
handler = BaseLLMHTTPHandler()
mock_config = Mock()
mock_config.validate_anthropic_messages_environment = Mock(
return_value=({"x-api-key": "k"}, "https://api.anthropic.com")
)
mock_config.should_filter_anthropic_beta_headers = Mock(return_value=False)
mock_config.transform_anthropic_messages_request = Mock(
return_value={"model": "claude", "messages": []}
)
mock_config.get_complete_url = Mock(return_value="https://api.anthropic.com/v1/messages")
mock_config.sign_request = Mock(return_value=({"x-api-key": "k"}, None))
mock_config.max_retry_on_anthropic_messages_http_error = 1
mock_config.get_async_streaming_response_iterator = Mock(return_value=Mock())
ok_response = Mock()
ok_response.raise_for_status = Mock(return_value=None)
ok_response.headers = httpx.Headers({})
mock_client = AsyncMock(spec=AsyncHTTPHandler)
mock_client.post = AsyncMock(return_value=ok_response)
logging_obj = Mock()
logging_obj.model_call_details = {}
logging_obj.dynamic_success_callbacks = []
await handler.async_anthropic_messages_handler(
model="claude",
messages=[{"role": "user", "content": "hi"}],
anthropic_messages_provider_config=mock_config,
anthropic_messages_optional_request_params={},
custom_llm_provider="anthropic",
litellm_params=GenericLiteLLMParams(timeout=9.0, stream_timeout=0.7),
logging_obj=logging_obj,
client=mock_client,
stream=True,
kwargs={},
)
assert mock_client.post.await_args.kwargs["stream"] is True
assert mock_client.post.await_args.kwargs["timeout"] == 0.7
@pytest.mark.asyncio
async def test_anthropic_post_uses_prebuilt_body_without_redumping():
"""When the caller passes a pre-serialized (unsigned) body, attempt 0 must
@ -1894,7 +2035,9 @@ async def test_anthropic_invalid_thinking_signature_retry_resigns_bedrock_reques
ok_response = httpx.Response(200, json={"id": "msg_1"}, request=httpx.Request("POST", request_url))
class FakeAsyncClient:
async def post(self, url, headers, data, stream=False, logging_obj=None):
async def post(
self, url, headers, data, stream=False, logging_obj=None, timeout=None
):
posts.append({"headers": dict(headers), "data": data})
return invalid_signature_response if len(posts) == 1 else ok_response