This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git


The following commit(s) were added to refs/heads/main by this push:
     new 5f4b54ac fix(llm): replace retry with tenacity in ollama.py (#367)
5f4b54ac is described below

commit 5f4b54ac3c6aaa60e124c8e5b4c76fbd744d66d4
Author: KAI <[email protected]>
AuthorDate: Tue Jun 23 10:03:29 2026 +0530

    fix(llm): replace retry with tenacity in ollama.py (#367)
    
    ## Summary
    
    `ollama.py` was using the abandoned `retry` PyPI package (last
    maintained 2016)
    while `openai.py` and `litellm.py` in the same directory already use
    `tenacity`.
    This PR brings `ollama.py` in line with the established patterns.
    
    Closes #365
---
 hugegraph-llm/pyproject.toml                       |   2 +-
 .../src/hugegraph_llm/models/llms/ollama.py        |  48 +++--
 .../src/tests/models/llms/test_ollama_client.py    | 193 ++++++++++++++++++++-
 pyproject.toml                                     |   2 +-
 4 files changed, 222 insertions(+), 23 deletions(-)

diff --git a/hugegraph-llm/pyproject.toml b/hugegraph-llm/pyproject.toml
index cd2452d9..5327e813 100644
--- a/hugegraph-llm/pyproject.toml
+++ b/hugegraph-llm/pyproject.toml
@@ -47,7 +47,7 @@ dependencies = [
     # LLM specific dependencies
     "openai",
     "ollama",
-    "retry",
+    "tenacity",
     "tiktoken",
     "nltk",
     "gradio",
diff --git a/hugegraph-llm/src/hugegraph_llm/models/llms/ollama.py 
b/hugegraph-llm/src/hugegraph_llm/models/llms/ollama.py
index 515d8d69..bfffb689 100644
--- a/hugegraph-llm/src/hugegraph_llm/models/llms/ollama.py
+++ b/hugegraph-llm/src/hugegraph_llm/models/llms/ollama.py
@@ -19,8 +19,14 @@
 import json
 from typing import Any, AsyncGenerator, Callable, Dict, Generator, List, 
Optional
 
+import httpx
 import ollama
-from retry import retry
+from tenacity import (
+    retry,
+    retry_if_exception_type,
+    stop_after_attempt,
+    wait_exponential,
+)
 
 from hugegraph_llm.models.llms.base import BaseLLM
 from hugegraph_llm.utils.log import log
@@ -34,13 +40,17 @@ class OllamaClient(BaseLLM):
         self.client = ollama.Client(host=f"http://{host}:{port}";, **kwargs)
         self.async_client = ollama.AsyncClient(host=f"http://{host}:{port}";, 
**kwargs)
 
-    @retry(tries=3, delay=1)
+    @retry(
+        stop=stop_after_attempt(3),
+        wait=wait_exponential(multiplier=1, min=4, max=10),
+        retry=retry_if_exception_type((ollama.ResponseError, 
httpx.ConnectError, httpx.TimeoutException)),
+    )
     def generate(
         self,
         messages: Optional[List[Dict[str, Any]]] = None,
         prompt: Optional[str] = None,
     ) -> str:
-        """Comment"""
+        """Generate a response to the query messages/prompt."""
         if messages is None:
             assert prompt is not None, "Messages or prompt must be provided."
             messages = [{"role": "user", "content": prompt}]
@@ -56,17 +66,21 @@ class OllamaClient(BaseLLM):
             }
             log.info("Token usage: %s", json.dumps(usage))
             return response["message"]["content"]
-        except Exception as e:
-            print(f"Retrying LLM call {e}")
-            raise e
-
-    @retry(tries=3, delay=1)
+        except (ollama.ResponseError, httpx.ConnectError, 
httpx.TimeoutException) as e:
+            log.error("Retrying LLM call %s", e)
+            raise
+
+    @retry(
+        stop=stop_after_attempt(3),
+        wait=wait_exponential(multiplier=1, min=4, max=10),
+        retry=retry_if_exception_type((ollama.ResponseError, 
httpx.ConnectError, httpx.TimeoutException)),
+    )
     async def agenerate(
         self,
         messages: Optional[List[Dict[str, Any]]] = None,
         prompt: Optional[str] = None,
     ) -> str:
-        """Comment"""
+        """Generate a response to the query messages/prompt asynchronously."""
         if messages is None:
             assert prompt is not None, "Messages or prompt must be provided."
             messages = [{"role": "user", "content": prompt}]
@@ -82,9 +96,9 @@ class OllamaClient(BaseLLM):
             }
             log.info("Token usage: %s", json.dumps(usage))
             return response["message"]["content"]
-        except Exception as e:
-            print(f"Retrying LLM call {e}")
-            raise e
+        except (ollama.ResponseError, httpx.ConnectError, 
httpx.TimeoutException) as e:
+            log.error("Retrying LLM call %s", e)
+            raise
 
     def generate_streaming(
         self,
@@ -92,7 +106,7 @@ class OllamaClient(BaseLLM):
         prompt: Optional[str] = None,
         on_token_callback: Optional[Callable] = None,
     ) -> Generator[str, None, None]:
-        """Comment"""
+        """Stream response tokens one by one."""
         if messages is None:
             assert prompt is not None, "Messages or prompt must be provided."
             messages = [{"role": "user", "content": prompt}]
@@ -112,7 +126,7 @@ class OllamaClient(BaseLLM):
         prompt: Optional[str] = None,
         on_token_callback: Optional[Callable] = None,
     ) -> AsyncGenerator[str, None]:
-        """Comment"""
+        """Stream response tokens one by one."""
         if messages is None:
             assert prompt is not None, "Messages or prompt must be provided."
             messages = [{"role": "user", "content": prompt}]
@@ -124,9 +138,9 @@ class OllamaClient(BaseLLM):
                 if on_token_callback:
                     on_token_callback(token)
                 yield token
-        except Exception as e:
-            print(f"Retrying LLM call {e}")
-            raise e
+        except (ollama.ResponseError, httpx.ConnectError, 
httpx.TimeoutException) as e:
+            log.error("Error in agenerate_streaming: %s", e)
+            raise
 
     def num_tokens_from_string(
         self,
diff --git a/hugegraph-llm/src/tests/models/llms/test_ollama_client.py 
b/hugegraph-llm/src/tests/models/llms/test_ollama_client.py
index bbc014fd..39d97f78 100644
--- a/hugegraph-llm/src/tests/models/llms/test_ollama_client.py
+++ b/hugegraph-llm/src/tests/models/llms/test_ollama_client.py
@@ -15,27 +15,212 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import asyncio
 import os
 import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import ollama
+import pytest
+from tenacity import RetryError, wait_none
 
 from hugegraph_llm.models.llms.ollama import OllamaClient
 
+pytestmark = pytest.mark.contract
+
+# Minimal dict response matching the structure ollama.Client.chat() returns
+_MOCK_RESPONSE = {
+    "prompt_eval_count": 10,
+    "eval_count": 5,
+    "message": {"content": "Paris"},
+}
+
+
+class TestOllamaClientRetryPolicy(unittest.TestCase):
+    """Mock-based contract tests for the Tenacity retry policy in OllamaClient.
+
+    These tests do not require a running Ollama service.  They verify:
+      - retryable exceptions (ollama.ResponseError, httpx.ConnectError,
+        httpx.TimeoutException) trigger the configured number of attempts;
+      - non-retryable exceptions (e.g. ValueError) are NOT retried;
+      - a transient failure followed by success resolves correctly.
+    """
+
+    def setUp(self):
+        # Zero out exponential wait so retry tests complete in milliseconds.
+        # Tenacity exposes the Retrying object on the decorated function via
+        # the .retry attribute; its .wait field is mutable.
+        self._orig_generate_wait = OllamaClient.generate.retry.wait
+        self._orig_agenerate_wait = OllamaClient.agenerate.retry.wait
+        OllamaClient.generate.retry.wait = wait_none()
+        OllamaClient.agenerate.retry.wait = wait_none()
+
+    def tearDown(self):
+        OllamaClient.generate.retry.wait = self._orig_generate_wait
+        OllamaClient.agenerate.retry.wait = self._orig_agenerate_wait
+
+    # ------------------------------------------------------------------ #
+    # generate()                                                           #
+    # ------------------------------------------------------------------ #
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.Client")
+    def test_generate_returns_content_on_success(self, mock_client_class):
+        """Happy path: generate() returns the message content string."""
+        mock_client = MagicMock()
+        mock_client.chat.return_value = _MOCK_RESPONSE
+        mock_client_class.return_value = mock_client
+
+        result = OllamaClient(model="llama3").generate(prompt="hello")
+
+        self.assertEqual(result, "Paris")
+        mock_client.chat.assert_called_once()
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.Client")
+    def test_generate_retries_response_error_exhausts_all_attempts(self, 
mock_client_class):
+        """ollama.ResponseError is retryable; all 3 attempts are made."""
+        mock_client = MagicMock()
+        mock_client.chat.side_effect = ollama.ResponseError("model not found")
+        mock_client_class.return_value = mock_client
+
+        with self.assertRaises(RetryError):
+            OllamaClient(model="llama3").generate(prompt="hello")
+
+        self.assertEqual(mock_client.chat.call_count, 3)
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.Client")
+    def test_generate_retries_connect_error_exhausts_all_attempts(self, 
mock_client_class):
+        """httpx.ConnectError is retryable; all 3 attempts are made."""
+        mock_client = MagicMock()
+        mock_client.chat.side_effect = httpx.ConnectError("connection refused")
+        mock_client_class.return_value = mock_client
+
+        with self.assertRaises(RetryError):
+            OllamaClient(model="llama3").generate(prompt="hello")
+
+        self.assertEqual(mock_client.chat.call_count, 3)
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.Client")
+    def test_generate_does_not_retry_non_retriable_error(self, 
mock_client_class):
+        """ValueError is not in the retry predicate; only 1 attempt is made."""
+        mock_client = MagicMock()
+        mock_client.chat.side_effect = ValueError("unexpected")
+        mock_client_class.return_value = mock_client
+
+        with self.assertRaises(ValueError):
+            OllamaClient(model="llama3").generate(prompt="hello")
+
+        mock_client.chat.assert_called_once()
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.Client")
+    def test_generate_succeeds_on_second_attempt(self, mock_client_class):
+        """Transient ResponseError on attempt 1, success on attempt 2."""
+        mock_client = MagicMock()
+        mock_client.chat.side_effect = [
+            ollama.ResponseError("transient"),
+            _MOCK_RESPONSE,
+        ]
+        mock_client_class.return_value = mock_client
+
+        result = OllamaClient(model="llama3").generate(prompt="hello")
+
+        self.assertEqual(result, "Paris")
+        self.assertEqual(mock_client.chat.call_count, 2)
+
+    # ------------------------------------------------------------------ #
+    # agenerate()                                                          #
+    # ------------------------------------------------------------------ #
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.AsyncClient")
+    def test_agenerate_retries_connect_error_exhausts_all_attempts(self, 
mock_async_client_class):
+        """httpx.ConnectError is retryable in agenerate(); all 3 attempts 
made."""
+        mock_async_client = MagicMock()
+        mock_async_client.chat = 
AsyncMock(side_effect=httpx.ConnectError("connection refused"))
+        mock_async_client_class.return_value = mock_async_client
+
+        async def run():
+            with self.assertRaises(RetryError):
+                await OllamaClient(model="llama3").agenerate(prompt="hello")
+            self.assertEqual(mock_async_client.chat.call_count, 3)
+
+        asyncio.run(run())
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.AsyncClient")
+    def test_agenerate_retries_timeout_exception_exhausts_all_attempts(self, 
mock_async_client_class):
+        """httpx.TimeoutException is retryable in agenerate(); all 3 attempts 
made."""
+        mock_async_client = MagicMock()
+        mock_async_client.chat = 
AsyncMock(side_effect=httpx.TimeoutException("timed out"))
+        mock_async_client_class.return_value = mock_async_client
+
+        async def run():
+            with self.assertRaises(RetryError):
+                await OllamaClient(model="llama3").agenerate(prompt="hello")
+            self.assertEqual(mock_async_client.chat.call_count, 3)
+
+        asyncio.run(run())
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.AsyncClient")
+    def test_agenerate_does_not_retry_non_retriable_error(self, 
mock_async_client_class):
+        """ValueError is not retryable in agenerate(); only 1 attempt is 
made."""
+        mock_async_client = MagicMock()
+        mock_async_client.chat = 
AsyncMock(side_effect=ValueError("unexpected"))
+        mock_async_client_class.return_value = mock_async_client
+
+        async def run():
+            with self.assertRaises(ValueError):
+                await OllamaClient(model="llama3").agenerate(prompt="hello")
+            mock_async_client.chat.assert_called_once()
+
+        asyncio.run(run())
+
+    @patch("hugegraph_llm.models.llms.ollama.ollama.AsyncClient")
+    def test_agenerate_succeeds_on_second_attempt(self, 
mock_async_client_class):
+        """Transient ResponseError on attempt 1, success on attempt 2."""
+        mock_async_client = MagicMock()
+        mock_async_client.chat = 
AsyncMock(side_effect=[ollama.ResponseError("transient"), _MOCK_RESPONSE])
+        mock_async_client_class.return_value = mock_async_client
+
+        async def run():
+            result = await 
OllamaClient(model="llama3").agenerate(prompt="hello")
+            self.assertEqual(result, "Paris")
+            self.assertEqual(mock_async_client.chat.call_count, 2)
+
+        asyncio.run(run())
+
+
+class TestOllamaClientExternalService(unittest.TestCase):
+    """Integration tests that require a live Ollama service.
+
+    Skipped in CI via SKIP_EXTERNAL_SERVICES=true (set in conftest.py).
+    """
 
-class TestOllamaClient(unittest.TestCase):
     def setUp(self):
         self.skip_external = os.getenv("SKIP_EXTERNAL_SERVICES", 
"false").lower() == "true"
 
-    @unittest.skipIf(os.getenv("SKIP_EXTERNAL_SERVICES", "false").lower() == 
"true", "Skipping external service tests")
+    @unittest.skipIf(
+        os.getenv("SKIP_EXTERNAL_SERVICES", "false").lower() == "true",
+        "Skipping external service tests",
+    )
     def test_generate(self):
         ollama_client = OllamaClient(model="llama3:8b-instruct-fp16")
         response = ollama_client.generate(prompt="What is the capital of 
France?")
         print(response)
 
-    @unittest.skipIf(os.getenv("SKIP_EXTERNAL_SERVICES", "false").lower() == 
"true", "Skipping external service tests")
+    @unittest.skipIf(
+        os.getenv("SKIP_EXTERNAL_SERVICES", "false").lower() == "true",
+        "Skipping external service tests",
+    )
     def test_stream_generate(self):
         ollama_client = OllamaClient(model="llama3:8b-instruct-fp16")
 
         def on_token_callback(chunk):
             print(chunk, end="", flush=True)
 
-        ollama_client.generate_streaming(prompt="What is the capital of 
France?", on_token_callback=on_token_callback)
+        ollama_client.generate_streaming(
+            prompt="What is the capital of France?",
+            on_token_callback=on_token_callback,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/pyproject.toml b/pyproject.toml
index bb63371f..a3d0bc37 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -114,7 +114,7 @@ constraint-dependencies = [
     # LLM dependencies
     "openai~=1.61.0",
     "ollama~=0.4.8",
-    "retry~=0.9.2",
+    "tenacity~=8.5.0",
     "tiktoken~=0.7.0",
     "nltk~=3.9.1",
     "gradio~=5.20.0",

Reply via email to