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

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

commit ed0f272c4df405bd5280c2e5e8e10c368d4116a4
Merge: 53f3ebac da710c8c
Author: imbajin <[email protected]>
AuthorDate: Tue Jun 2 20:53:46 2026 +0800

    chore: merge latest goal-test into goal-scan
    
    - merge latest goal-test CI, fixture, and review updates
    
    - preserve goal-scan quality scan docs and runtime fixes
    
    - combine 401 auth handling with typed response errors
    
    - keep production smoke tests on namespaced fixtures

 .github/workflows/hugegraph-llm.yml                | 40 +++++------
 .github/workflows/hugegraph-python-client.yml      | 28 ++++----
 .workflow/quality-program/quality-state.json       | 45 +++++++++++-
 .../reports/final-quality-report.md                | 30 ++++++--
 .../reports/production-change-ledger.md            |  7 +-
 docs/quality/coverage-ratchet.md                   | 11 +++
 hugegraph-llm/src/hugegraph_llm/api/rag_api.py     |  2 +
 .../src/hugegraph_llm/config/models/base_config.py | 29 +++++++-
 .../config/models/base_prompt_config.py            | 16 ++++-
 .../hugegraph_llm/demo/rag_demo/configs_block.py   |  3 +-
 hugegraph-llm/src/tests/api/test_rag_api.py        |  8 ++-
 hugegraph-llm/src/tests/config/test_config.py      | 15 ++++
 hugegraph-llm/src/tests/conftest.py                | 58 +++++++++++++--
 .../src/tests/integration/test_core_kg_smoke.py    | 82 ++++++++++------------
 .../integration/test_core_text2gremlin_smoke.py    |  3 +-
 .../tests/integration/test_hugegraph_boundary.py   | 60 +++-------------
 .../src/tests/integration/test_kg_construction.py  |  3 +-
 .../operators/llm_op/test_gremlin_generate.py      |  2 +-
 .../tests/operators/llm_op/test_keyword_extract.py |  2 +-
 .../llm_op/test_property_graph_extract.py          |  2 +-
 .../src/pyhugegraph/utils/util.py                  |  3 +
 .../src/tests/api/test_response_validation.py      | 16 ++++-
 22 files changed, 309 insertions(+), 156 deletions(-)

diff --cc hugegraph-llm/src/hugegraph_llm/api/rag_api.py
index 9b9c7d35,4f5a80c3..d57f2812
--- a/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
+++ b/hugegraph-llm/src/hugegraph_llm/api/rag_api.py
@@@ -168,8 -162,10 +168,10 @@@ def rag_http_api
      @router.post("/config/llm", status_code=status.HTTP_201_CREATED)
      def llm_config_api(req: LLMConfigRequest):
          llm_settings.chat_llm_type = req.llm_type
+         llm_settings.extract_llm_type = req.llm_type
+         llm_settings.text2gql_llm_type = req.llm_type
  
 -        if req.llm_type == "openai":
 +        if req.llm_type in ("openai", "litellm"):
              res = apply_llm_conf(
                  req.api_key,
                  req.api_base,
diff --cc hugegraph-llm/src/tests/integration/test_kg_construction.py
index cbca4102,379cb350..da793db2
--- a/hugegraph-llm/src/tests/integration/test_kg_construction.py
+++ b/hugegraph-llm/src/tests/integration/test_kg_construction.py
@@@ -15,89 -15,219 +15,90 @@@
  # specific language governing permissions and limitations
  # under the License.
  
 -# pylint: disable=import-error,wrong-import-position,unused-argument
 -
 -import json
 -import os
 -import unittest
 -from unittest.mock import patch
 -
  import pytest
- from fixtures.fake_llm import FakeLLM
+ 
 -# 导入测试工具
 -from src.tests.test_utils import (
 -    create_test_document,
 -    should_skip_external,
 -    with_mock_openai_client,
 -)
 -
 -pytestmark = [pytest.mark.external, pytest.mark.slow]
 -
 -
 -# Create mock classes to replace missing modules
 -class OpenAILLM:
 -    """Mock OpenAILLM class"""
 -
 -    def __init__(self, api_key=None, model=None):
 -        self.api_key = api_key
 -        self.model = model or "gpt-3.5-turbo"
 -
 -    def generate(self, prompt):
 -        # Return a mock response
 -        return f"This is a mock response to '{prompt}'"
 -
 -
 -class KGConstructor:
 -    """Mock KGConstructor class"""
 -
 -    def __init__(self, llm, schema):
 -        self.llm = llm
 -        self.schema = schema
 -
 -    def extract_entities(self, document):
 -        # Mock entity extraction
 -        if "张三" in document.content:
 -            return [
 -                {"type": "Person", "name": "张三", "properties": {"occupation": 
"Software Engineer"}},
 -                {
 -                    "type": "Company",
 -                    "name": "ABC Company",
 -                    "properties": {"industry": "Technology", "location": 
"Beijing"},
 -                },
 -            ]
 -        if "李四" in document.content:
 -            return [
 -                {"type": "Person", "name": "李四", "properties": {"occupation": 
"Data Scientist"}},
 -                {"type": "Person", "name": "张三", "properties": {"occupation": 
"Software Engineer"}},
 -            ]
 -        if "ABC Company" in document.content or "ABC公司" in document.content:
 -            return [
 -                {
 -                    "type": "Company",
 -                    "name": "ABC Company",
 -                    "properties": {"industry": "Technology", "location": 
"Beijing"},
 -                }
 -            ]
 -        return []
 -
 -    def extract_relations(self, document):
 -        # Mock relation extraction
 -        if "张三" in document.content and ("ABC Company" in document.content or 
"ABC公司" in document.content):
 -            return [
 -                {
 -                    "source": {"type": "Person", "name": "张三"},
 -                    "relation": "works_for",
 -                    "target": {"type": "Company", "name": "ABC Company"},
 -                }
 -            ]
 -        if "李四" in document.content and "张三" in document.content:
 -            return [
 -                {
 -                    "source": {"type": "Person", "name": "李四"},
 -                    "relation": "colleague",
 -                    "target": {"type": "Person", "name": "张三"},
 -                }
 -            ]
 -        return []
 -
 -    def construct_from_documents(self, documents):
 -        # Mock knowledge graph construction
 -        entities = []
 -        relations = []
 -
 -        # Collect all entities and relations
 -        for doc in documents:
 -            entities.extend(self.extract_entities(doc))
 -            relations.extend(self.extract_relations(doc))
 -
 -        # Deduplicate entities
 -        unique_entities = []
 -        entity_names = set()
 -        for entity in entities:
 -            if entity["name"] not in entity_names:
 -                unique_entities.append(entity)
 -                entity_names.add(entity["name"])
 -
 -        return {"entities": unique_entities, "relations": relations}
 -
 -
 -class TestKGConstruction(unittest.TestCase):
 -    """Integration tests for knowledge graph construction"""
 -
 -    def setUp(self):
 -        """Setup work before testing"""
 -        # Skip if external service tests should be skipped
 -        if should_skip_external():
 -            self.skipTest("Skipping tests that require external services")
 -
 -        # Load test schema
 -        schema_path = os.path.join(os.path.dirname(__file__), 
"../data/kg/schema.json")
 -        with open(schema_path, "r", encoding="utf-8") as f:
 -            self.schema = json.load(f)
 -
 -        # Create test documents
 -        self.test_docs = [
 -            create_test_document("张三 is a software engineer working at ABC 
Company."),
 -            create_test_document("李四 is 张三's colleague and works as a data 
scientist."),
 -            create_test_document("ABC Company is a tech company headquartered 
in Beijing."),
 -        ]
 -
 -        # Create LLM model
 -        self.llm = OpenAILLM()
 -
 -        # Create knowledge graph constructor
 -        self.kg_constructor = KGConstructor(llm=self.llm, schema=self.schema)
 -
 -    @with_mock_openai_client
 -    def test_entity_extraction(self, *args):
 -        """Test entity extraction"""
 -        # Extract entities from document
 -        doc = self.test_docs[0]
 -        entities = self.kg_constructor.extract_entities(doc)
 -
 -        # Verify extracted entities
 -        self.assertEqual(len(entities), 2)
 -        self.assertEqual(entities[0]["name"], "张三")
 -        self.assertEqual(entities[1]["name"], "ABC Company")
 -
 -    @with_mock_openai_client
 -    def test_relation_extraction(self, *args):
 -        """Test relation extraction"""
 -        # Extract relations from document
 -        doc = self.test_docs[0]
 -        relations = self.kg_constructor.extract_relations(doc)
 -
 -        # Verify extracted relations
 -        self.assertEqual(len(relations), 1)
 -        self.assertEqual(relations[0]["source"]["name"], "张三")
 -        self.assertEqual(relations[0]["relation"], "works_for")
 -        self.assertEqual(relations[0]["target"]["name"], "ABC Company")
 -
 -    @with_mock_openai_client
 -    def test_kg_construction_end_to_end(self, *args):
 -        """Test end-to-end knowledge graph construction process"""
 -        # Mock entity and relation extraction
 -        mock_entities = [
 -            {"type": "Person", "name": "张三", "properties": {"occupation": 
"Software Engineer"}},
 -            {"type": "Company", "name": "ABC Company", "properties": 
{"industry": "Technology"}},
 -        ]
 -
 -        mock_relations = [
 -            {
 -                "source": {"type": "Person", "name": "张三"},
 -                "relation": "works_for",
 -                "target": {"type": "Company", "name": "ABC Company"},
 -            }
++from tests.fixtures.fake_llm import FakeLLM
 +
 +pytestmark = [pytest.mark.smoke, pytest.mark.integration]
 +
 +
 +PROPERTY_GRAPH_SCHEMA = {
 +    "vertexlabels": [
 +        {
 +            "id": 1,
 +            "name": "person",
 +            "properties": ["name", "occupation"],
 +            "primary_keys": ["name"],
 +            "nullable_keys": ["occupation"],
 +        },
 +        {
 +            "id": 2,
 +            "name": "company",
 +            "properties": ["name", "industry"],
 +            "primary_keys": ["name"],
 +            "nullable_keys": ["industry"],
 +        },
 +    ],
 +    "edgelabels": [
 +        {
 +            "name": "works_for",
 +            "source_label": "person",
 +            "target_label": "company",
 +            "properties": ["since"],
 +        }
 +    ],
 +}
 +
 +
 +def test_graph_extract_flow_builds_production_property_graph_pipeline():
 +    from hugegraph_llm.flows.graph_extract import GraphExtractFlow
 +
 +    pipeline = GraphExtractFlow().build_flow(
 +        schema=PROPERTY_GRAPH_SCHEMA,
 +        texts=["Marko works for HugeGraph."],
 +        example_prompt="extract property graph",
 +        extract_type="property_graph",
 +        language="en",
 +    )
 +    prepared = pipeline.getGParamWithNoEmpty("wkflow_input")
 +    dot = pipeline.dump()
 +
 +    assert prepared.extract_type == "property_graph"
 +    assert prepared.texts == ["Marko works for HugeGraph."]
 +    assert 'label="schema_node"' in dot
 +    assert 'label="chunk_split"' in dot
 +    assert 'label="graph_extract"' in dot
 +
 +
 +def 
test_property_graph_extract_uses_production_operator_with_deterministic_llm():
 +    from hugegraph_llm.operators.llm_op.property_graph_extract import 
PropertyGraphExtract
 +
 +    llm = FakeLLM(
 +        [
 +            """{
 +                "vertices": [
 +                    {"label": "person", "properties": {"name": "Marko", 
"occupation": "developer"}},
 +                    {"label": "company", "properties": {"name": "HugeGraph", 
"industry": "graph"}}
 +                ],
 +                "edges": [
 +                    {
 +                        "label": "works_for",
 +                        "properties": {"since": "2024"},
 +                        "source": {"label": "person", "properties": {"name": 
"Marko"}},
 +                        "target": {"label": "company", "properties": {"name": 
"HugeGraph"}}
 +                    }
 +                ]
 +            }"""
          ]
 -
 -        # Mock KG constructor methods
 -        with (
 -            patch.object(self.kg_constructor, "extract_entities", 
return_value=mock_entities),
 -            patch.object(self.kg_constructor, "extract_relations", 
return_value=mock_relations),
 -        ):
 -            # Construct knowledge graph - use only one document to avoid 
duplicate relations from mocking
 -            kg = 
self.kg_constructor.construct_from_documents([self.test_docs[0]])
 -
 -            # Verify knowledge graph
 -            self.assertIsNotNone(kg)
 -            self.assertEqual(len(kg["entities"]), 2)
 -            self.assertEqual(len(kg["relations"]), 1)
 -
 -            # Verify entities
 -            entity_names = [e["name"] for e in kg["entities"]]
 -            self.assertIn("张三", entity_names)
 -            self.assertIn("ABC Company", entity_names)
 -
 -            # Verify relations
 -            relation = kg["relations"][0]
 -            self.assertEqual(relation["source"]["name"], "张三")
 -            self.assertEqual(relation["relation"], "works_for")
 -            self.assertEqual(relation["target"]["name"], "ABC Company")
 -
 -    def test_schema_validation(self):
 -        """Test schema validation"""
 -        # Verify schema structure
 -        self.assertIn("vertices", self.schema)
 -        self.assertIn("edges", self.schema)
 -
 -        # Verify entity types
 -        vertex_labels = [v["vertex_label"] for v in self.schema["vertices"]]
 -        self.assertIn("person", vertex_labels)
 -
 -        # Verify relation types
 -        edge_labels = [e["edge_label"] for e in self.schema["edges"]]
 -        self.assertIn("works_at", edge_labels)
 -
 -
 -if __name__ == "__main__":
 -    unittest.main()
 +    )
 +    context = PropertyGraphExtract(llm=llm, example_prompt=None).run(
 +        {
 +            "schema": PROPERTY_GRAPH_SCHEMA,
 +            "chunks": ["Marko works for HugeGraph."],
 +        }
 +    )
 +
 +    assert [vertex["label"] for vertex in context["vertices"]] == ["person", 
"company"]
 +    assert context["edges"][0]["label"] == "works_for"
 +    assert context["edges"][0]["outV"] == "1:Marko"
 +    assert context["edges"][0]["inV"] == "2:HugeGraph"
diff --cc hugegraph-python-client/src/pyhugegraph/utils/util.py
index 4d509cae,ffe0ff90..9cab72ff
--- a/hugegraph-python-client/src/pyhugegraph/utils/util.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/util.py
@@@ -1,193 -1,143 +1,196 @@@
 -# 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 json
 -import traceback
 -
 -import requests
 -
 -from pyhugegraph.utils.exceptions import (
 -    NotAuthorizedError,
 -    NotFoundError,
 -    ServiceUnavailableError,
 -)
 -from pyhugegraph.utils.log import log
 -
 -
 -def create_exception(response_content):
 -    try:
 -        data = json.loads(response_content)
 -        if "ServiceUnavailableException" in data.get("exception", ""):
 -            raise ServiceUnavailableError(
 -                f'ServiceUnavailableException, "message": 
"{data["message"]}", "cause": "{data["cause"]}"'
 -            )
 -    except (json.JSONDecodeError, KeyError) as e:
 -        raise Exception(f"Error parsing response content: 
{response_content}") from e
 -    raise Exception(response_content)
 -
 -
 -def check_if_authorized(response):
 -    if response.status_code == 401:
 -        raise NotAuthorizedError(f"Please check your username and password. 
{response.content!s}")
 -    return True
 -
 -
 -def check_if_success(response, error=None):
 -    if (not str(response.status_code).startswith("20")) and 
check_if_authorized(response):
 -        if error is None:
 -            error = NotFoundError(response.content)
 -
 -        req = response.request
 -        req_body = req.body if req.body else "Empty body"
 -        response_body = response.text if response.text else "Empty body"
 -        log.error(
 -            "Error-Client: Request URL: %s, Request Body: %s, Response Body: 
%s",
 -            req.url,
 -            req_body,
 -            response_body,
 -        )
 -        raise error
 -    return True
 -
 -
 -class ResponseValidation:
 -    def __init__(self, content_type: str = "json", strict: bool = True) -> 
None:
 -        super().__init__()
 -        self._content_type = content_type
 -        self._strict = strict
 -
 -    def __call__(self, response: requests.Response, method: str, path: str):
 -        """
 -        Validate the HTTP response according to the provided content type and 
strictness.
 -
 -        :param response: HTTP response object
 -        :param method: HTTP method used (e.g., 'GET', 'POST')
 -        :param path: URL path of the request
 -        :return: Parsed response content or empty dict if none applicable
 -        """
 -        result = {}
 -
 -        try:
 -            response.raise_for_status()
 -            if response.status_code == 204:
 -                log.debug("No content returned (204) for %s: %s", method, 
path)
 -            else:
 -                if self._content_type == "raw":
 -                    result = response
 -                elif self._content_type == "json":
 -                    result = response.json()
 -                elif self._content_type == "text":
 -                    result = response.text
 -                else:
 -                    raise ValueError(f"Unknown content type: 
{self._content_type}")
 -
 -        except requests.exceptions.HTTPError as e:
 -            if not self._strict and response.status_code == 404:
 -                log.info("Resource %s not found (404)", path)
 -            else:
 -                if response.status_code == 401:
 -                    check_if_authorized(response)
 -
 -                try:
 -                    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("message")
 -                            or body.get("exception")
 -                            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")
 -                log.error(
 -                    "%s: %s\n[Body]: %s\n[Server Exception]: %s",
 -                    method,
 -                    str(e).encode("utf-8").decode("unicode_escape"),
 -                    req_body,
 -                    details,
 -                )
 -
 -                if response.status_code == 404:
 -                    raise NotFoundError(response.content) from e
 -                raise Exception(f"Server Exception: {details}") from e
 -
 -        except Exception:  # pylint: disable=broad-exception-caught
 -            log.error("Unhandled exception occurred: %s", 
traceback.format_exc())
 -
 -        return result
 -
 -    def __repr__(self) -> str:
 -        return f"ResponseValidation(content_type={self._content_type}, 
strict={self._strict})"
 +# 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 json
 +import re
 +import traceback
 +
 +import requests
 +
 +from pyhugegraph.utils.exceptions import (
 +    NotAuthorizedError,
 +    NotFoundError,
 +    ResponseParseError,
 +    ServerError,
 +    ServiceUnavailableError,
 +)
 +from pyhugegraph.utils.log import log
 +
 +REDACTED_VALUE = "***REDACTED***"
 +SENSITIVE_KEY_PARTS = (
 +    "api_key",
 +    "authorization",
 +    "password",
 +    "passwd",
 +    "pwd",
 +    "secret",
 +    "token",
 +)
 +
 +
 +def _is_sensitive_key(key) -> bool:
 +    key_lower = str(key).lower()
 +    return any(part in key_lower for part in SENSITIVE_KEY_PARTS)
 +
 +
 +def redact_sensitive_data(value):
 +    if isinstance(value, dict):
 +        return {
 +            key: REDACTED_VALUE if _is_sensitive_key(key) else 
redact_sensitive_data(item)
 +            for key, item in value.items()
 +        }
 +    if isinstance(value, list):
 +        return [redact_sensitive_data(item) for item in value]
 +    if isinstance(value, tuple):
 +        return tuple(redact_sensitive_data(item) for item in value)
 +    if isinstance(value, bytes):
 +        value = value.decode("utf-8", errors="replace")
 +    if isinstance(value, str):
 +        try:
 +            parsed = json.loads(value)
 +        except json.JSONDecodeError:
 +            redacted = re.sub(
 +                
r'(?i)("?[a-z0-9_-]*(?:api_key|authorization|password|passwd|pwd|secret|token)[a-z0-9_-]*"?\s*[:=]\s*)"[^"]*"',
 +                rf"\1\"{REDACTED_VALUE}\"",
 +                value,
 +            )
 +            return re.sub(
 +                
r"(?i)(api_key|authorization|password|passwd|pwd|secret|token)=([^&\s]+)",
 +                rf"\1={REDACTED_VALUE}",
 +                redacted,
 +            )
 +        return json.dumps(redact_sensitive_data(parsed), ensure_ascii=False)
 +    return value
 +
 +
 +def create_exception(response_content):
 +    try:
 +        data = json.loads(response_content)
 +        if "ServiceUnavailableException" in data.get("exception", ""):
 +            raise ServiceUnavailableError(
 +                f'ServiceUnavailableException, "message": 
"{data["message"]}", "cause": "{data["cause"]}"'
 +            )
 +    except (json.JSONDecodeError, KeyError) as e:
 +        raise Exception(f"Error parsing response content: 
{response_content}") from e
 +    raise Exception(response_content)
 +
 +
 +def check_if_authorized(response):
 +    if response.status_code == 401:
 +        raise NotAuthorizedError(f"Please check your username and password. 
{response.content!s}")
 +    return True
 +
 +
 +def check_if_success(response, error=None):
 +    if (not str(response.status_code).startswith("20")) and 
check_if_authorized(response):
 +        if error is None:
 +            error = NotFoundError(response.content)
 +
 +        req = response.request
 +        req_body = redact_sensitive_data(req.body) if req.body else "Empty 
body"
 +        response_body = response.text if response.text else "Empty body"
 +        log.error(
 +            "Error-Client: Request URL: %s, Request Body: %s, Response Body: 
%s",
 +            req.url,
 +            req_body,
 +            response_body,
 +        )
 +        raise error
 +    return True
 +
 +
 +class ResponseValidation:
 +    def __init__(self, content_type: str = "json", strict: bool = True) -> 
None:
 +        super().__init__()
 +        self._content_type = content_type
 +        self._strict = strict
 +
 +    def __call__(self, response: requests.Response, method: str, path: str):
 +        """
 +        Validate the HTTP response according to the provided content type and 
strictness.
 +
 +        :param response: HTTP response object
 +        :param method: HTTP method used (e.g., 'GET', 'POST')
 +        :param path: URL path of the request
 +        :return: Parsed response content or empty dict if none applicable
 +        """
 +        result = {}
 +
 +        try:
 +            response.raise_for_status()
 +            if response.status_code == 204:
 +                log.debug("No content returned (204) for %s: %s", method, 
path)
 +            else:
 +                if self._content_type == "raw":
 +                    result = response
 +                elif self._content_type == "json":
 +                    result = response.json()
 +                elif self._content_type == "text":
 +                    result = response.text
 +                else:
 +                    raise ValueError(f"Unknown content type: 
{self._content_type}")
 +
 +        except requests.exceptions.HTTPError as e:
 +            if not self._strict and response.status_code == 404:
 +                log.info("Resource %s not found (404)", path)
 +            else:
++                if response.status_code == 401:
++                    check_if_authorized(response)
++
 +                try:
 +                    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("message")
 +                            or body.get("exception")
 +                            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 = redact_sensitive_data(response.request.body) if 
response.request.body else "Empty body"
 +                if isinstance(req_body, str):
 +                    req_body = 
req_body.encode("utf-8").decode("unicode_escape")
 +                log.error(
 +                    "%s: %s\n[Body]: %s\n[Server Exception]: %s",
 +                    method,
 +                    str(e).encode("utf-8").decode("unicode_escape"),
 +                    req_body,
 +                    details,
 +                )
 +
 +                if response.status_code == 404:
 +                    raise NotFoundError(response.content) from e
 +                if response.status_code >= 400:
 +                    raise ServerError(f"Server Exception: {details}") from e
 +                raise e
 +
 +        except Exception as e:
 +            log.error("Unhandled exception occurred: %s", 
traceback.format_exc())
 +            raise ResponseParseError(f"Failed to parse {self._content_type} 
response for {method} {path}") from e
 +
 +        return result
 +
 +    def __repr__(self) -> str:
 +        return f"ResponseValidation(content_type={self._content_type}, 
strict={self._strict})"
diff --cc hugegraph-python-client/src/tests/api/test_response_validation.py
index 0653db55,77dc3c9a..72b76a2f
--- a/hugegraph-python-client/src/tests/api/test_response_validation.py
+++ b/hugegraph-python-client/src/tests/api/test_response_validation.py
@@@ -20,7 -20,7 +20,7 @@@ from unittest.mock import Moc
  
  import pytest
  import requests
- from pyhugegraph.utils.exceptions import ResponseParseError, ServerError
 -from pyhugegraph.utils.exceptions import NotAuthorizedError
++from pyhugegraph.utils.exceptions import NotAuthorizedError, 
ResponseParseError, ServerError
  from pyhugegraph.utils.util import ResponseValidation
  
  pytestmark = pytest.mark.contract
@@@ -78,33 -78,20 +78,47 @@@ class TestResponseValidation(unittest.T
          with self.assertRaisesRegex(Exception, "Server Exception: not json"):
              validator(response, "POST", "/gremlin")
  
 +    def test_malformed_success_json_raises_parse_error(self):
 +        response = Mock(spec=requests.Response)
 +        response.status_code = 200
 +        response.text = "not json"
 +        response.content = b"not json"
 +        response.json.side_effect = ValueError("not json")
 +        response.raise_for_status.return_value = None
 +        validator = ResponseValidation()
 +
 +        with pytest.raises(ResponseParseError, match="Failed to parse json 
response"):
 +            validator(response, "GET", "/graphs/hugegraph")
 +
 +    def test_error_log_redacts_sensitive_request_body(self):
 +        response = self._mock_error_response(
 +            {"message": "bad request"},
 +            '{"message":"bad request"}',
 +        )
 +        response.request.body = 
'{"user_name":"marko","user_password":"super-secret"}'
 +        validator = ResponseValidation()
 +
 +        with pytest.raises(ServerError), 
unittest.mock.patch("pyhugegraph.utils.util.log.error") as log_error:
 +            validator(response, "POST", "/auth/users")
 +
 +        logged_args = str(log_error.call_args)
 +        assert "super-secret" not in logged_args
 +        assert "***REDACTED***" in logged_args
 +
+     def test_unauthorized_error_preserves_not_authorized_type(self):
+         response = Mock(spec=requests.Response)
+         response.status_code = 401
+         response.text = 
'{"exception":"NotAuthorizedException","message":"Authentication failed"}'
+         response.content = response.text.encode("utf-8")
+         response.request = Mock(body="Empty body", 
url="http://127.0.0.1:8080/graphs";)
+         response.raise_for_status.side_effect = 
requests.exceptions.HTTPError("401 Client Error")
+         validator = ResponseValidation()
+ 
+         with pytest.raises(NotAuthorizedError) as exc_info:
+             validator(response, method="GET", path="/graphs")
+ 
+         assert "Please check your username and password" in 
str(exc_info.value)
+ 
  
  if __name__ == "__main__":
      unittest.main()

Reply via email to