diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index eaf78b7bcf5..0fac3a1a923 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -4,6 +4,7 @@ Helper functions to handle images passed in messages import base64 +import httpx from httpx import Response import litellm @@ -13,9 +14,32 @@ from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB MAX_IMGS_IN_MEMORY = 10 +# Maximum connect timeout for image URL fetching (seconds). +# Caps the connect phase independently of litellm.request_timeout so that +# unreachable hosts (e.g. internal IPs) fail fast instead of blocking +# the caller (and potentially the asyncio event loop) for minutes. +_IMAGE_FETCH_CONNECT_TIMEOUT = 5.0 + +# Maximum overall timeout for a single image fetch attempt (seconds). +_IMAGE_FETCH_OVERALL_TIMEOUT = 30.0 + in_memory_cache = InMemoryCache(max_size_in_memory=MAX_IMGS_IN_MEMORY) +def _get_image_fetch_timeout() -> httpx.Timeout: + """ + Build an httpx.Timeout with a capped connect timeout for image fetching. + + The connect timeout is always capped at _IMAGE_FETCH_CONNECT_TIMEOUT to + prevent long hangs on unreachable hosts. The overall (read/write/pool) + timeout uses _IMAGE_FETCH_OVERALL_TIMEOUT. + """ + return httpx.Timeout( + timeout=_IMAGE_FETCH_OVERALL_TIMEOUT, + connect=_IMAGE_FETCH_CONNECT_TIMEOUT, + ) + + def _process_image_response(response: Response, url: str) -> str: if response.status_code != 200: raise litellm.ImageFetchError( @@ -82,14 +106,19 @@ async def async_convert_url_to_base64(url: str) -> str: return cached_result client = litellm.module_level_aclient - for _ in range(3): + image_timeout = _get_image_fetch_timeout() + for attempt in range(3): try: - response = await client.get(url, follow_redirects=True) + response = await client.get( + url, follow_redirects=True, timeout=image_timeout + ) return _process_image_response(response, url) except litellm.ImageFetchError: raise - except Exception: - pass + except Exception as e: + verbose_logger.warning( + "Image fetch attempt %d/3 failed for url=%s: %s", attempt + 1, url, e + ) raise litellm.ImageFetchError( f"Error: Unable to fetch image from URL after 3 attempts. url={url}" ) @@ -107,15 +136,19 @@ def convert_url_to_base64(url: str) -> str: return cached_result client = litellm.module_level_client - for _ in range(3): + image_timeout = _get_image_fetch_timeout() + for attempt in range(3): try: - response = client.get(url, follow_redirects=True) + response = client.get( + url, follow_redirects=True, timeout=image_timeout + ) return _process_image_response(response, url) except litellm.ImageFetchError: raise except Exception as e: - verbose_logger.exception(e) - pass + verbose_logger.warning( + "Image fetch attempt %d/3 failed for url=%s: %s", attempt + 1, url, e + ) raise litellm.ImageFetchError( f"Error: Unable to fetch image from URL after 3 attempts. url={url}", ) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 489a56daf8e..d9a6964af9d 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -486,6 +486,7 @@ class AsyncHTTPHandler: params: Optional[dict] = None, headers: Optional[dict] = None, follow_redirects: Optional[bool] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, ): # Set follow_redirects to UseClientDefault if None _follow_redirects = ( @@ -495,9 +496,16 @@ class AsyncHTTPHandler: params = params or {} params.update(HTTPHandler.extract_query_params(url)) - response = await self.client.get( - url, params=params, headers=headers, follow_redirects=_follow_redirects # type: ignore - ) + # Build kwargs for the underlying httpx call + kwargs: dict = { + "params": params, + "headers": headers, + "follow_redirects": _follow_redirects, + } + if timeout is not None: + kwargs["timeout"] = timeout + + response = await self.client.get(url, **kwargs) # type: ignore return response @track_llm_api_timing() @@ -1007,6 +1015,7 @@ class HTTPHandler: params: Optional[dict] = None, headers: Optional[dict] = None, follow_redirects: Optional[bool] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, ): # Set follow_redirects to UseClientDefault if None _follow_redirects = ( @@ -1015,11 +1024,15 @@ class HTTPHandler: params = params or {} params.update(self.extract_query_params(url)) - response = self.client.get( - url, - params=params, - headers=headers, - ) + # Build kwargs for the underlying httpx call + kwargs: dict = { + "params": params, + "headers": headers, + } + if timeout is not None: + kwargs["timeout"] = timeout + + response = self.client.get(url, **kwargs) return response diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 9c2939b2da5..62ce89ed42e 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -1,17 +1,19 @@ from unittest.mock import patch +import httpx import pytest from httpx import Request, Response import litellm from litellm import constants from litellm.litellm_core_utils.prompt_templates.image_handling import ( + _get_image_fetch_timeout, convert_url_to_base64, ) class DummyClient: - def get(self, url, follow_redirects=True): + def get(self, url, follow_redirects=True, timeout=None): return Response(status_code=404, request=Request("GET", url)) @@ -53,7 +55,7 @@ class LargeImageClient: self.size_mb = size_mb self.include_content_length = include_content_length - def get(self, url, follow_redirects=True): + def get(self, url, follow_redirects=True, timeout=None): size_bytes = int(self.size_mb * 1024 * 1024) headers = {"Content-Type": "image/jpeg"} if self.include_content_length: @@ -76,7 +78,7 @@ class StreamingLargeImageClient: self.size_mb = size_mb self.include_content_length = include_content_length - def get(self, url, follow_redirects=True): + def get(self, url, follow_redirects=True, timeout=None): size_bytes = int(self.size_mb * 1024 * 1024) headers = {"Content-Type": "image/jpeg"} if self.include_content_length: @@ -158,7 +160,7 @@ class SmallImageClient: Client that returns a small valid image. """ - def get(self, url, follow_redirects=True): + def get(self, url, follow_redirects=True, timeout=None): size_bytes = 1024 headers = { "Content-Type": "image/jpeg", @@ -217,3 +219,109 @@ def test_image_size_limit_disabled(monkeypatch): assert "Image URL download is disabled" in str(excinfo.value) assert "MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0" in str(excinfo.value) + + +# ---------- Image fetch timeout tests ---------- + + +def test_image_fetch_timeout_has_capped_connect(): + """ + Verify _get_image_fetch_timeout() returns an httpx.Timeout whose connect + phase is capped at 5 s regardless of litellm.request_timeout. + """ + timeout = _get_image_fetch_timeout() + assert isinstance(timeout, httpx.Timeout) + assert timeout.connect == 5.0 + assert timeout.read == 30.0 + + +class TimeoutCapturingClient: + """Dummy client that records the timeout passed to .get().""" + + def __init__(self): + self.last_timeout = None + + def get(self, url, follow_redirects=True, timeout=None): + self.last_timeout = timeout + return Response( + status_code=200, + headers={"Content-Type": "image/jpeg", "Content-Length": "4"}, + content=b"\xff\xd8\xff\xe0", + request=Request("GET", url), + ) + + +def test_convert_url_to_base64_passes_capped_timeout(monkeypatch): + """ + Ensure convert_url_to_base64 passes a capped timeout to the HTTP client so + that unreachable hosts fail fast instead of blocking the caller for minutes. + """ + client = TimeoutCapturingClient() + monkeypatch.setattr(litellm, "module_level_client", client) + + convert_url_to_base64("https://example.com/tiny.jpg") + + assert client.last_timeout is not None + assert isinstance(client.last_timeout, httpx.Timeout) + assert client.last_timeout.connect == 5.0 + + +class ConnectTimeoutClient: + """Simulates an unreachable host that raises ConnectTimeout.""" + + def __init__(self): + self.call_count = 0 + + def get(self, url, follow_redirects=True, timeout=None): + self.call_count += 1 + raise httpx.ConnectTimeout( + "Timed out connecting", request=Request("GET", url) + ) + + +def test_unreachable_host_fails_after_3_retries(monkeypatch): + """ + When the host is unreachable, convert_url_to_base64 should retry 3 times + then raise ImageFetchError — not hang indefinitely. + """ + client = ConnectTimeoutClient() + monkeypatch.setattr(litellm, "module_level_client", client) + + with pytest.raises(litellm.ImageFetchError) as excinfo: + convert_url_to_base64("http://10.254.3.71/uploads/photo.png") + + assert "after 3 attempts" in str(excinfo.value) + assert client.call_count == 3 + + +@pytest.mark.asyncio +async def test_async_convert_url_to_base64_passes_capped_timeout(monkeypatch): + """ + Ensure async_convert_url_to_base64 passes a capped timeout to the async + HTTP client. + """ + from litellm.litellm_core_utils.prompt_templates.image_handling import ( + async_convert_url_to_base64, + ) + + class AsyncTimeoutCapturingClient: + def __init__(self): + self.last_timeout = None + + async def get(self, url, follow_redirects=True, timeout=None): + self.last_timeout = timeout + return Response( + status_code=200, + headers={"Content-Type": "image/png", "Content-Length": "4"}, + content=b"\x89PNG", + request=Request("GET", url), + ) + + client = AsyncTimeoutCapturingClient() + monkeypatch.setattr(litellm, "module_level_aclient", client) + + await async_convert_url_to_base64("https://example.com/tiny.png") + + assert client.last_timeout is not None + assert isinstance(client.last_timeout, httpx.Timeout) + assert client.last_timeout.connect == 5.0