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 2df0b6e1 fix(llm): improve rag demo error handling (#335)
2df0b6e1 is described below

commit 2df0b6e1583a900211c31f6f169d836363f1d7c1
Author: Makoto <[email protected]>
AuthorDate: Wed May 20 15:20:54 2026 +0800

    fix(llm): improve rag demo error handling (#335)
    
    ## Summary
    
    - Make RAG demo auto-reload opt-in through `HG_DEV_RELOAD` instead of
    enabling reload by default.
    - Improve LLM error handling so LiteLLM/OpenAI runtime failures surface
    consistently instead of being silently converted to ordinary result
    strings.
    - Update Ollama async embeddings to use the batch `embed(input=...)` API
    and validate malformed embedding responses.
    - Make graph summary fetching handle HugeGraph Gremlin response shapes
    more defensively while preserving real execution failures.
    - Clarify HugeGraph schema and HTTP error messages so callers receive
    more actionable failures.
    
    ---------
    Co-authored-by: imbajin <[email protected]>
---
 .../src/hugegraph_llm/demo/rag_demo/app.py         |  3 +-
 .../src/hugegraph_llm/models/embeddings/ollama.py  | 39 +++++++---
 .../src/hugegraph_llm/models/llms/litellm.py       | 14 ++--
 .../src/hugegraph_llm/models/llms/openai.py        | 10 ++-
 hugegraph-llm/src/hugegraph_llm/nodes/base_node.py | 12 ++-
 .../hugegraph_llm/nodes/llm_node/schema_build.py   |  3 +-
 .../operators/hugegraph_op/fetch_graph_data.py     | 14 +++-
 .../operators/hugegraph_op/schema_manager.py       |  8 +-
 .../models/embeddings/test_ollama_embedding.py     | 62 ++++++++++++++++
 .../src/tests/models/llms/test_litellm_client.py   | 83 +++++++++++++++++++++
 .../src/tests/models/llms/test_openai_client.py    | 37 ++++++++++
 .../hugegraph_op/test_fetch_graph_data.py          | 86 ++++++++++++++++++++++
 .../operators/hugegraph_op/test_schema_manager.py  |  2 +-
 .../src/pyhugegraph/utils/util.py                  | 18 ++++-
 .../src/tests/api/test_response_validation.py      | 56 ++++++++++++++
 15 files changed, 414 insertions(+), 33 deletions(-)

diff --git a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py 
b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py
index 34cd0711..c687df6d 100644
--- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py
+++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/app.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import argparse
+import os
 
 import gradio as gr
 import uvicorn
@@ -202,5 +203,5 @@ if __name__ == "__main__":
         host=args.host,
         port=args.port,
         factory=True,
-        reload=True,
+        reload=os.getenv("HG_DEV_RELOAD") == "1",
     )
diff --git a/hugegraph-llm/src/hugegraph_llm/models/embeddings/ollama.py 
b/hugegraph-llm/src/hugegraph_llm/models/embeddings/ollama.py
index 195ea6f6..bb0abccf 100644
--- a/hugegraph-llm/src/hugegraph_llm/models/embeddings/ollama.py
+++ b/hugegraph-llm/src/hugegraph_llm/models/embeddings/ollama.py
@@ -75,20 +75,41 @@ class OllamaEmbedding(BaseEmbedding):
         all_embeddings = []
         for i in range(0, len(texts), batch_size):
             batch = texts[i : i + batch_size]
-            response = self.client.embed(model=self.model, 
input=batch)["embeddings"]
-            all_embeddings.extend([list(inner_sequence) for inner_sequence in 
response])
+            response = self.client.embed(model=self.model, input=batch)
+            all_embeddings.extend(self._get_embeddings_from_response(response))
         return all_embeddings
 
+    def _get_embeddings_from_response(self, response) -> List[List[float]]:
+        if "embeddings" not in response:
+            raise ValueError("Ollama embedding response missing 'embeddings'.")
+        embeddings = response["embeddings"]
+        if not embeddings:
+            raise ValueError("Ollama embedding response returned no 
embeddings.")
+        return [list(inner_sequence) for inner_sequence in embeddings]
+
     async def async_get_text_embedding(self, text: str) -> List[float]:
         """Get embedding for a single text asynchronously."""
-        response = await self.async_client.embeddings(model=self.model, 
prompt=text)
-        return list(response["embedding"])
+        if not hasattr(self.async_client, "embed"):
+            error_message = (
+                "The required 'embed' method was not found on the Ollama async 
client. "
+                "Please ensure your ollama library is up-to-date and supports 
batch embedding. "
+            )
+            raise AttributeError(error_message)
+
+        response = await self.async_client.embed(model=self.model, 
input=[text])
+        return self._get_embeddings_from_response(response)[0]
 
     async def async_get_texts_embeddings(self, texts: List[str], batch_size: 
int = 32) -> List[List[float]]:
-        # Ollama python client may not provide batch async embeddings; 
fallback per item
-        # batch_size parameter included for consistency with base class 
signature
+        if not hasattr(self.async_client, "embed"):
+            error_message = (
+                "The required 'embed' method was not found on the Ollama async 
client. "
+                "Please ensure your ollama library is up-to-date and supports 
batch embedding. "
+            )
+            raise AttributeError(error_message)
+
         results: List[List[float]] = []
-        for t in texts:
-            response = await self.async_client.embeddings(model=self.model, 
prompt=t)
-            results.append(list(response["embedding"]))
+        for i in range(0, len(texts), batch_size):
+            batch = texts[i : i + batch_size]
+            response = await self.async_client.embed(model=self.model, 
input=batch)
+            results.extend(self._get_embeddings_from_response(response))
         return results
diff --git a/hugegraph-llm/src/hugegraph_llm/models/llms/litellm.py 
b/hugegraph-llm/src/hugegraph_llm/models/llms/litellm.py
index dcf479c2..60ad481f 100644
--- a/hugegraph-llm/src/hugegraph_llm/models/llms/litellm.py
+++ b/hugegraph-llm/src/hugegraph_llm/models/llms/litellm.py
@@ -51,7 +51,8 @@ class LiteLLMClient(BaseLLM):
     @retry(
         stop=stop_after_attempt(2),
         wait=wait_exponential(multiplier=1, min=2, max=5),
-        retry=retry_if_exception_type((RateLimitError, BudgetExceededError, 
APIError)),
+        retry=retry_if_exception_type((RateLimitError, APIError)),
+        reraise=True,
     )
     def generate(
         self,
@@ -75,12 +76,13 @@ class LiteLLMClient(BaseLLM):
             return response.choices[0].message.content
         except (RateLimitError, BudgetExceededError, APIError) as e:
             log.error("Error in LiteLLM call: %s", e)
-            return f"Error: {str(e)}"
+            raise
 
     @retry(
         stop=stop_after_attempt(2),
         wait=wait_exponential(multiplier=1, min=2, max=5),
-        retry=retry_if_exception_type((RateLimitError, BudgetExceededError, 
APIError)),
+        retry=retry_if_exception_type((RateLimitError, APIError)),
+        reraise=True,
     )
     async def agenerate(
         self,
@@ -104,7 +106,7 @@ class LiteLLMClient(BaseLLM):
             return response.choices[0].message.content
         except (RateLimitError, BudgetExceededError, APIError) as e:
             log.error("Error in async LiteLLM call: %s", e)
-            return f"Error: {str(e)}"
+            raise
 
     def generate_streaming(
         self,
@@ -138,7 +140,7 @@ class LiteLLMClient(BaseLLM):
             return result
         except (RateLimitError, BudgetExceededError, APIError) as e:
             log.error("Error in streaming LiteLLM call: %s", e)
-            return f"Error: {str(e)}"
+            raise
 
     async def agenerate_streaming(
         self,
@@ -170,7 +172,7 @@ class LiteLLMClient(BaseLLM):
                     yield chunk.choices[0].delta.content
         except (RateLimitError, BudgetExceededError, APIError) as e:
             log.error("Error in async streaming LiteLLM call: %s", e)
-            yield f"Error: {str(e)}"
+            raise
 
     def num_tokens_from_string(self, string: str) -> int:
         """Get token count from string."""
diff --git a/hugegraph-llm/src/hugegraph_llm/models/llms/openai.py 
b/hugegraph-llm/src/hugegraph_llm/models/llms/openai.py
index f7a6d3f9..3370d47d 100644
--- a/hugegraph-llm/src/hugegraph_llm/models/llms/openai.py
+++ b/hugegraph-llm/src/hugegraph_llm/models/llms/openai.py
@@ -70,7 +70,10 @@ class OpenAIClient(BaseLLM):
                 max_tokens=self.max_tokens,
                 messages=messages,
             )
-            log.info("Token usage: %s", completions.usage.model_dump_json())
+            if not completions.choices:
+                raise RuntimeError(f"Empty choices in LLM response: 
{str(completions)[:200]}")
+            if completions.usage:
+                log.info("Token usage: %s", 
completions.usage.model_dump_json())
             return completions.choices[0].message.content
         # catch context length / do not retry
         except openai.BadRequestError as e:
@@ -105,7 +108,10 @@ class OpenAIClient(BaseLLM):
                 max_tokens=self.max_tokens,
                 messages=messages,
             )
-            log.info("Token usage: %s", completions.usage.model_dump_json())
+            if not completions.choices:
+                raise RuntimeError(f"Empty choices in LLM response: 
{str(completions)[:200]}")
+            if completions.usage:
+                log.info("Token usage: %s", 
completions.usage.model_dump_json())
             return completions.choices[0].message.content
         # catch context length / do not retry
         except openai.BadRequestError as e:
diff --git a/hugegraph-llm/src/hugegraph_llm/nodes/base_node.py 
b/hugegraph-llm/src/hugegraph_llm/nodes/base_node.py
index 7ed8af8a..4da43e90 100644
--- a/hugegraph-llm/src/hugegraph_llm/nodes/base_node.py
+++ b/hugegraph-llm/src/hugegraph_llm/nodes/base_node.py
@@ -13,6 +13,7 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
+import traceback
 from typing import Dict, Optional
 
 from pycgraph import CStatus, GNode
@@ -22,6 +23,11 @@ from hugegraph_llm.state.ai_state import WkFlowInput, 
WkFlowState
 from hugegraph_llm.utils.log import log
 
 
+def _format_node_err(node, exc: Exception, prefix: str = "Node failed") -> str:
+    node_info = f"Node type: {type(node).__name__}, Node object: {node}"
+    return f"{prefix}: {exc}\n{node_info}\n{traceback.format_exc()}"
+
+
 class BaseNode(GNode):
     """
     Base class for workflow nodes, providing context management and operation 
scheduling.
@@ -70,10 +76,8 @@ class BaseNode(GNode):
         try:
             res = self.operator_schedule(data_json)
         except (ValueError, TypeError, KeyError, NotImplementedError) as exc:
-            import traceback
-
-            node_info = f"Node type: {type(self).__name__}, Node object: 
{self}"
-            err_msg = f"Node failed: 
{exc}\n{node_info}\n{traceback.format_exc()}"
+            err_msg = _format_node_err(self, exc)
+            log.error(err_msg)
             return CStatus(-1, err_msg)
         # For unexpected exceptions, re-raise to let them propagate or be 
caught elsewhere
 
diff --git a/hugegraph-llm/src/hugegraph_llm/nodes/llm_node/schema_build.py 
b/hugegraph-llm/src/hugegraph_llm/nodes/llm_node/schema_build.py
index 01b2ca64..e40d69c9 100644
--- a/hugegraph-llm/src/hugegraph_llm/nodes/llm_node/schema_build.py
+++ b/hugegraph-llm/src/hugegraph_llm/nodes/llm_node/schema_build.py
@@ -81,8 +81,7 @@ class SchemaBuildNode(BaseNode):
     def operator_schedule(self, data_json):
         try:
             schema_result = self.schema_builder.run(data_json)
-
             return {"schema": schema_result}
         except (ValueError, RuntimeError) as e:
             log.error("Failed to generate schema: %s", e)
-            return {"schema": f"Schema generation failed: {e}"}
+            raise ValueError(f"Failed to generate schema: {e}") from e
diff --git 
a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/fetch_graph_data.py 
b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/fetch_graph_data.py
index c3f427e9..8916356a 100644
--- a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/fetch_graph_data.py
+++ b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/fetch_graph_data.py
@@ -44,8 +44,16 @@ class FetchGraphData:
         return res;
         """
 
-        result = self.graph.gremlin().exec(groovy_code)["data"]
-
+        response = self.graph.gremlin().exec(groovy_code)
+        result = response.get("data") if isinstance(response, dict) else None
         if isinstance(result, list) and len(result) > 0:
-            graph_summary.update({key: result[i].get(key) for i, key in 
enumerate(keys)})
+            if len(result) == 1 and isinstance(result[0], dict):
+                graph_summary.update({key: result[0].get(key) for key in keys})
+            else:
+                graph_summary.update(
+                    {
+                        key: result[i].get(key) if i < len(result) and 
isinstance(result[i], dict) else None
+                        for i, key in enumerate(keys)
+                    }
+                )
         return graph_summary
diff --git 
a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py 
b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py
index c265646f..c51cb958 100644
--- a/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py
+++ b/hugegraph-llm/src/hugegraph_llm/operators/hugegraph_op/schema_manager.py
@@ -17,6 +17,7 @@
 from typing import Any, Dict, Optional
 
 from pyhugegraph.client import PyHugeClient
+from requests.exceptions import RequestException
 
 from hugegraph_llm.config import huge_settings
 
@@ -57,9 +58,12 @@ class SchemaManager:
     def run(self, context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
         if context is None:
             context = {}
-        schema = self.schema.getSchema()
+        try:
+            schema = self.schema.getSchema()
+        except RequestException as e:
+            raise ValueError(f"Failed to connect to HugeGraph to get schema 
'{self.graph_name}': {e}") from e
         if not schema["vertexlabels"] and not schema["edgelabels"]:
-            raise Exception(f"Can not get {self.graph_name}'s schema from 
HugeGraph!")
+            raise ValueError(f"Cannot get {self.graph_name}'s schema from 
HugeGraph!")
 
         context.update({"schema": schema})
         # TODO: enhance the logic here
diff --git a/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py 
b/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
index c919a2d6..e7a2702a 100644
--- a/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
+++ b/hugegraph-llm/src/tests/models/embeddings/test_ollama_embedding.py
@@ -18,6 +18,7 @@
 
 import os
 import unittest
+from unittest.mock import AsyncMock, MagicMock
 
 from hugegraph_llm.models.embeddings.base import SimilarityMode
 from hugegraph_llm.models.embeddings.ollama import OllamaEmbedding
@@ -40,3 +41,64 @@ class TestOllamaEmbedding(unittest.TestCase):
         embedding2 = ollama_embedding.get_text_embedding("bye world")
         similarity = OllamaEmbedding.similarity(embedding1, embedding2, 
SimilarityMode.DEFAULT)
         print(similarity)
+
+    def test_async_get_texts_embeddings_preserves_batch_order(self):
+        ollama_embedding = OllamaEmbedding(model="test-model")
+        ollama_embedding.async_client = AsyncMock()
+        ollama_embedding.async_client.embed.side_effect = [
+            {"embeddings": [[1.0], [2.0]]},
+            {"embeddings": [[3.0]]},
+        ]
+
+        async def run_async_test():
+            result = await ollama_embedding.async_get_texts_embeddings(["a", 
"b", "c"], batch_size=2)
+            self.assertEqual(result, [[1.0], [2.0], [3.0]])
+            self.assertEqual(ollama_embedding.async_client.embed.call_count, 2)
+            
ollama_embedding.async_client.embed.assert_any_call(model="test-model", 
input=["a", "b"])
+            
ollama_embedding.async_client.embed.assert_any_call(model="test-model", 
input=["c"])
+
+        import asyncio
+
+        asyncio.run(run_async_test())
+
+    def test_async_get_text_embedding_requires_embeddings_key(self):
+        ollama_embedding = OllamaEmbedding(model="test-model")
+        ollama_embedding.async_client = AsyncMock()
+        ollama_embedding.async_client.embed.return_value = {}
+
+        async def run_async_test():
+            with self.assertRaisesRegex(ValueError, "missing 'embeddings'"):
+                await ollama_embedding.async_get_text_embedding("a")
+
+        import asyncio
+
+        asyncio.run(run_async_test())
+
+    def test_get_texts_embeddings_requires_embeddings_key(self):
+        ollama_embedding = OllamaEmbedding(model="test-model")
+        ollama_embedding.client = MagicMock()
+        ollama_embedding.client.embed.return_value = {}
+
+        with self.assertRaisesRegex(ValueError, "missing 'embeddings'"):
+            ollama_embedding.get_texts_embeddings(["a"])
+
+    def test_get_texts_embeddings_requires_non_empty_embeddings(self):
+        ollama_embedding = OllamaEmbedding(model="test-model")
+        ollama_embedding.client = MagicMock()
+        ollama_embedding.client.embed.return_value = {"embeddings": []}
+
+        with self.assertRaisesRegex(ValueError, "returned no embeddings"):
+            ollama_embedding.get_texts_embeddings(["a"])
+
+    def test_async_get_text_embedding_requires_non_empty_embeddings(self):
+        ollama_embedding = OllamaEmbedding(model="test-model")
+        ollama_embedding.async_client = AsyncMock()
+        ollama_embedding.async_client.embed.return_value = {"embeddings": []}
+
+        async def run_async_test():
+            with self.assertRaisesRegex(ValueError, "returned no embeddings"):
+                await ollama_embedding.async_get_text_embedding("a")
+
+        import asyncio
+
+        asyncio.run(run_async_test())
diff --git a/hugegraph-llm/src/tests/models/llms/test_litellm_client.py 
b/hugegraph-llm/src/tests/models/llms/test_litellm_client.py
new file mode 100644
index 00000000..3b27d780
--- /dev/null
+++ b/hugegraph-llm/src/tests/models/llms/test_litellm_client.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import asyncio
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from litellm.exceptions import APIError, BudgetExceededError
+
+from hugegraph_llm.models.llms.litellm import LiteLLMClient
+
+
+class TestLiteLLMClient(unittest.TestCase):
+    def test_budget_exceeded_error_is_not_retried(self):
+        client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+        error = BudgetExceededError(current_cost=2.0, max_budget=1.0)
+
+        with patch("hugegraph_llm.models.llms.litellm.completion", 
side_effect=error) as mock_completion:
+            with self.assertRaises(BudgetExceededError):
+                client.generate(prompt="hello")
+
+        mock_completion.assert_called_once()
+
+    def test_generate_retries_api_error_and_reraises_original_exception(self):
+        client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+        error = APIError(status_code=500, message="upstream failed", 
llm_provider="openai", model="gpt-4.1-mini")
+
+        with patch("hugegraph_llm.models.llms.litellm.completion", 
side_effect=error) as mock_completion:
+            with self.assertRaises(APIError):
+                client.generate(prompt="hello")
+
+        self.assertEqual(mock_completion.call_count, 2)
+
+    def test_generate_streaming_reraises_api_error(self):
+        client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+        error = APIError(status_code=500, message="upstream failed", 
llm_provider="openai", model="gpt-4.1-mini")
+
+        with patch("hugegraph_llm.models.llms.litellm.completion", 
side_effect=error):
+            with self.assertRaises(APIError):
+                client.generate_streaming(prompt="hello")
+
+    def test_agenerate_retries_api_error_and_reraises_original_exception(self):
+        client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+        error = APIError(status_code=500, message="upstream failed", 
llm_provider="openai", model="gpt-4.1-mini")
+
+        async def run_async_test():
+            with patch("hugegraph_llm.models.llms.litellm.acompletion", 
new=AsyncMock(side_effect=error)) as mock_call:
+                with self.assertRaises(APIError):
+                    await client.agenerate(prompt="hello")
+
+            self.assertEqual(mock_call.call_count, 2)
+
+        asyncio.run(run_async_test())
+
+    def test_agenerate_streaming_reraises_api_error(self):
+        client = LiteLLMClient(model_name="openai/gpt-4.1-mini")
+        error = APIError(status_code=500, message="upstream failed", 
llm_provider="openai", model="gpt-4.1-mini")
+
+        async def run_async_test():
+            with patch("hugegraph_llm.models.llms.litellm.acompletion", 
new=AsyncMock(side_effect=error)):
+                with self.assertRaises(APIError):
+                    async for _ in client.agenerate_streaming(prompt="hello"):
+                        pass
+
+        asyncio.run(run_async_test())
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/hugegraph-llm/src/tests/models/llms/test_openai_client.py 
b/hugegraph-llm/src/tests/models/llms/test_openai_client.py
index b9f8a113..20e5aaac 100644
--- a/hugegraph-llm/src/tests/models/llms/test_openai_client.py
+++ b/hugegraph-llm/src/tests/models/llms/test_openai_client.py
@@ -92,6 +92,23 @@ class TestOpenAIClient(unittest.TestCase):
             messages=messages,
         )
 
+    @patch("hugegraph_llm.models.llms.openai.OpenAI")
+    def test_generate_raises_runtime_error_with_empty_choices(self, 
mock_openai_class):
+        """Test generate method with an empty choices response."""
+        # Setup mock client
+        mock_client = MagicMock()
+        empty_response = MagicMock()
+        empty_response.choices = []
+        empty_response.usage = None
+        mock_client.chat.completions.create.return_value = empty_response
+        mock_openai_class.return_value = mock_client
+
+        # Test the method
+        openai_client = OpenAIClient(model_name="gpt-3.5-turbo")
+
+        with self.assertRaisesRegex(RuntimeError, "Empty choices in LLM 
response"):
+            openai_client.generate(prompt="What is the capital of France?")
+
     @patch("hugegraph_llm.models.llms.openai.AsyncOpenAI")
     def test_agenerate(self, mock_async_openai_class):
         """Test agenerate method with mocked async OpenAI client."""
@@ -118,6 +135,26 @@ class TestOpenAIClient(unittest.TestCase):
 
         asyncio.run(run_async_test())
 
+    @patch("hugegraph_llm.models.llms.openai.AsyncOpenAI")
+    def test_agenerate_raises_runtime_error_with_empty_choices(self, 
mock_async_openai_class):
+        """Test agenerate method with an empty choices response."""
+        # Setup mock async client
+        mock_async_client = MagicMock()
+        empty_response = MagicMock()
+        empty_response.choices = []
+        empty_response.usage = None
+        mock_async_client.chat.completions.create = 
AsyncMock(return_value=empty_response)
+        mock_async_openai_class.return_value = mock_async_client
+
+        # Test the method
+        openai_client = OpenAIClient(model_name="gpt-3.5-turbo")
+
+        async def run_async_test():
+            with self.assertRaisesRegex(RuntimeError, "Empty choices in LLM 
response"):
+                await openai_client.agenerate(prompt="What is the capital of 
France?")
+
+        asyncio.run(run_async_test())
+
     @patch("hugegraph_llm.models.llms.openai.OpenAI")
     def test_stream_generate(self, mock_openai_class):
         """Test generate_streaming method with mocked OpenAI client."""
diff --git 
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py 
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
index 64c093ed..8527502d 100644
--- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
+++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_fetch_graph_data.py
@@ -73,6 +73,21 @@ class TestFetchGraphData(unittest.TestCase):
         self.assertIn("g.V().id().limit(10000).toList()", groovy_code)
         self.assertIn("g.E().id().limit(200).toList()", groovy_code)
 
+    def test_run_with_legacy_ordered_single_field_dicts_result(self):
+        """Test run method with legacy ordered single-field dict rows."""
+        # Setup mock
+        self.mock_gremlin.exec.return_value = self.sample_result
+
+        # Call the method
+        result = self.fetcher.run({})
+
+        # Verify the result
+        self.assertEqual(result["vertex_num"], 100)
+        self.assertEqual(result["edge_num"], 200)
+        self.assertEqual(result["vertices"], ["v1", "v2", "v3"])
+        self.assertEqual(result["edges"], ["e1", "e2"])
+        self.assertEqual(result["note"], "Only ≤10000 VIDs and ≤ 200 EIDs for 
brief overview .")
+
     def test_run_with_existing_graph_summary(self):
         """Test run method with existing graph_summary."""
         # Setup mock
@@ -97,6 +112,77 @@ class TestFetchGraphData(unittest.TestCase):
         self.assertEqual(result["edges"], ["e1", "e2"])
         self.assertIn("note", result)
 
+    def test_run_with_single_summary_dict_result(self):
+        """Test run method with Gremlin map result wrapped as one data row."""
+        # Setup mock
+        self.mock_gremlin.exec.return_value = {
+            "data": [
+                {
+                    "vertex_num": 100,
+                    "edge_num": 200,
+                    "vertices": ["v1", "v2", "v3"],
+                    "edges": ["e1", "e2"],
+                    "note": "Only ≤10000 VIDs and ≤ 200 EIDs for brief 
overview .",
+                }
+            ]
+        }
+
+        # Call the method
+        result = self.fetcher.run({})
+
+        # Verify the result
+        self.assertEqual(result["vertex_num"], 100)
+        self.assertEqual(result["edge_num"], 200)
+        self.assertEqual(result["vertices"], ["v1", "v2", "v3"])
+        self.assertEqual(result["edges"], ["e1", "e2"])
+        self.assertEqual(result["note"], "Only ≤10000 VIDs and ≤ 200 EIDs for 
brief overview .")
+
+    def test_run_with_partial_single_summary_dict_result(self):
+        """Test run method handles a single Gremlin map with missing summary 
fields."""
+        # Setup mock
+        self.mock_gremlin.exec.return_value = {
+            "data": [
+                {
+                    "vertex_num": 100,
+                    "vertices": ["v1", "v2", "v3"],
+                }
+            ]
+        }
+
+        # Call the method
+        result = self.fetcher.run({})
+
+        # Verify the result
+        self.assertEqual(result["vertex_num"], 100)
+        self.assertIsNone(result["edge_num"])
+        self.assertEqual(result["vertices"], ["v1", "v2", "v3"])
+        self.assertIsNone(result["edges"])
+        self.assertIsNone(result["note"])
+
+    def test_run_with_empty_single_summary_dict_result(self):
+        """Test run method treats one empty dict as a summary row."""
+        # Setup mock
+        self.mock_gremlin.exec.return_value = {"data": [{}]}
+
+        # Call the method
+        result = self.fetcher.run({})
+
+        # Verify the result
+        self.assertIsNone(result["vertex_num"])
+        self.assertIsNone(result["edge_num"])
+        self.assertIsNone(result["vertices"])
+        self.assertIsNone(result["edges"])
+        self.assertIsNone(result["note"])
+
+    def test_run_reraises_gremlin_exec_exception(self):
+        """Test run method does not hide Gremlin execution failures."""
+        # Setup mock
+        self.mock_gremlin.exec.side_effect = RuntimeError("Gremlin endpoint 
unavailable")
+
+        # Call the method and verify the original failure is visible
+        with self.assertRaisesRegex(RuntimeError, "Gremlin endpoint 
unavailable"):
+            self.fetcher.run({})
+
     def test_run_with_empty_result(self):
         """Test run method with empty result from gremlin."""
         # Setup mock
diff --git 
a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py 
b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
index a20857ae..b77ae01f 100644
--- a/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
+++ b/hugegraph-llm/src/tests/operators/hugegraph_op/test_schema_manager.py
@@ -158,7 +158,7 @@ class TestSchemaManager(unittest.TestCase):
             self.schema_manager.run({})
 
         # Verify the exception message
-        self.assertIn(f"Can not get {self.graph_name}'s schema from 
HugeGraph!", str(cm.exception))
+        self.assertIn(f"Cannot get {self.graph_name}'s schema from 
HugeGraph!", str(cm.exception))
 
     def test_run_with_existing_context(self):
         """Test run method with an existing context."""
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/util.py 
b/hugegraph-python-client/src/pyhugegraph/utils/util.py
index d8c833b4..b4a6f644 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/util.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/util.py
@@ -101,9 +101,21 @@ class ResponseValidation:
                 log.info("Resource %s not found (404)", path)
             else:
                 try:
-                    details = response.json().get("exception", "key 
'exception' not found")
-                except (ValueError, KeyError):
-                    details = "key 'exception' not found"
+                    body = response.json()
+                    if isinstance(body, dict):
+                        status = body.get("status")
+                        status_message = status.get("message") if 
isinstance(status, dict) else None
+                        details = (
+                            body.get("exception")
+                            or body.get("message")
+                            or status_message
+                            or response.text
+                            or "unknown error"
+                        )
+                    else:
+                        details = response.text or "unknown error"
+                except (ValueError, KeyError, AttributeError, TypeError):
+                    details = response.text or "unknown error"
 
                 req_body = response.request.body if response.request.body else 
"Empty body"
                 req_body = req_body.encode("utf-8").decode("unicode_escape")
diff --git a/hugegraph-python-client/src/tests/api/test_response_validation.py 
b/hugegraph-python-client/src/tests/api/test_response_validation.py
new file mode 100644
index 00000000..759d9718
--- /dev/null
+++ b/hugegraph-python-client/src/tests/api/test_response_validation.py
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest
+from unittest.mock import Mock
+
+import requests
+from pyhugegraph.utils.util import ResponseValidation
+
+
+class TestResponseValidation(unittest.TestCase):
+    def _mock_error_response(self, body, text):
+        response = Mock(spec=requests.Response)
+        response.status_code = 400
+        response.text = text
+        response.content = response.text.encode("utf-8")
+        response.json.return_value = body
+        response.request = Mock()
+        response.request.body = "g.V2()"
+        response.raise_for_status.side_effect = 
requests.exceptions.HTTPError("400 Client Error")
+        return response
+
+    def test_numeric_status_body_raises_server_exception_with_message(self):
+        response = self._mock_error_response(
+            {"status": 400, "message": "bad gremlin"},
+            '{"status":400,"message":"bad gremlin"}',
+        )
+        validator = ResponseValidation()
+
+        with self.assertRaisesRegex(Exception, "Server Exception: bad 
gremlin"):
+            validator(response, "POST", "/gremlin")
+
+    def 
test_non_dict_json_body_raises_server_exception_with_response_text(self):
+        response = self._mock_error_response(["bad gremlin"], "bad gremlin")
+        validator = ResponseValidation()
+
+        with self.assertRaisesRegex(Exception, "Server Exception: bad 
gremlin"):
+            validator(response, "POST", "/gremlin")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to