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 3405791cf80331f999d119607028312df6c91fd1
Author: imbajin <[email protected]>
AuthorDate: Sun May 31 11:49:20 2026 +0800

    fix(client): preserve transport error contracts
    
    - redact sensitive request fields before client logging
    - raise parse errors for malformed successful responses
    - preserve gremlin lower-level exception types
    - add contract tests for CS-002, CS-004, and CS-008
---
 .workflow/code-scan/code-scan-state.json           |   7 +-
 .../code-scan/reports/final-code-scan-report.md    |   5 +-
 .workflow/code-scan/reports/issues.md              |  18 +-
 .workflow/code-scan/reports/test-quality-ledger.md |   5 +-
 .../src/pyhugegraph/api/gremlin.py                 |  12 +-
 .../src/pyhugegraph/utils/exceptions.py            | 140 +++++----
 .../src/pyhugegraph/utils/huge_requests.py         | 310 +++++++++----------
 .../src/pyhugegraph/utils/util.py                  | 335 ++++++++++++---------
 .../src/tests/api/test_auth_routing.py             |  32 +-
 .../src/tests/api/test_gremlin.py                  |  30 +-
 .../src/tests/api/test_response_validation.py      |  28 ++
 11 files changed, 531 insertions(+), 391 deletions(-)

diff --git a/.workflow/code-scan/code-scan-state.json 
b/.workflow/code-scan/code-scan-state.json
index 25bea9be..19867c27 100644
--- a/.workflow/code-scan/code-scan-state.json
+++ b/.workflow/code-scan/code-scan-state.json
@@ -55,7 +55,10 @@
   },
   "issue_total": 37,
   "fixes_completed": [
-    "CS-001"
+    "CS-001",
+    "CS-002",
+    "CS-004",
+    "CS-008"
   ],
   "files_touched": [
     "docs/specs/2026-05-31-hugegraph-ai-code-scan-design.md",
@@ -122,5 +125,5 @@
     "uv run ruff check <edited-python-files>",
     "git status --short"
   ],
-  "next_recommended_action": "Address the client error-contract cluster: 
CS-002, CS-004, and CS-008."
+  "next_recommended_action": "Fix graph import correctness: CS-013, then add 
round-trip contract tests."
 }
diff --git a/.workflow/code-scan/reports/final-code-scan-report.md 
b/.workflow/code-scan/reports/final-code-scan-report.md
index a2639504..e51218fb 100644
--- a/.workflow/code-scan/reports/final-code-scan-report.md
+++ b/.workflow/code-scan/reports/final-code-scan-report.md
@@ -77,7 +77,7 @@ None.
 ## Recommended Next Fix Plan
 
 1. Fix `CS-001` immediately: lock down `/logs` path handling and default admin 
credentials. Status: fixed after the scan.
-2. Fix the client error-contract cluster: `CS-002`, `CS-004`, `CS-008`.
+2. Fix the client error-contract cluster: `CS-002`, `CS-004`, `CS-008`. 
Status: fixed after the scan.
 3. Fix graph import correctness: `CS-013`, then add the missing round-trip 
tests.
 4. Fix global config mutation and provider validation: `CS-011`, `CS-012`.
 5. Replace fake integration tests with production-flow smoke coverage before 
broader refactors.
@@ -85,3 +85,6 @@ None.
 ## Fix Progress After Scan
 
 - `CS-001`: fixed by rejecting insecure default admin tokens and validating 
log file names before `log_stream()` receives a path. Verified with 
`SKIP_EXTERNAL_SERVICES=true uv run pytest 
hugegraph-llm/src/tests/api/test_admin_api.py -q`.
+- `CS-002`: fixed by redacting sensitive request fields before client 
debug/error logs are emitted.
+- `CS-004`: fixed by raising `ResponseParseError` for malformed successful 
responses.
+- `CS-008`: fixed by preserving lower-level exceptions from Gremlin execution 
instead of wrapping every failure as `NotFoundError`.
diff --git a/.workflow/code-scan/reports/issues.md 
b/.workflow/code-scan/reports/issues.md
index 1fc226ed..04fed7fe 100644
--- a/.workflow/code-scan/reports/issues.md
+++ b/.workflow/code-scan/reports/issues.md
@@ -33,11 +33,11 @@ Issue IDs use `CS-NNN`. Priorities are P0 highest through 
P5 lowest.
 - Module: `hugegraph-python-client`
 - Layer: transport / auth
 - Paths: `hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py:145`, 
`hugegraph-python-client/src/pyhugegraph/utils/util.py:120`, 
`hugegraph-python-client/src/pyhugegraph/api/auth.py:43`, 
`hugegraph-python-client/src/pyhugegraph/api/auth.py:68`
-- Status: open
+- Status: fixed
 - Evidence: `HGraphSession.request()` logs raw `kwargs`, and 
`ResponseValidation` logs raw `response.request.body` on HTTP errors. Auth user 
create/modify requests serialize `user_password` into the request body.
 - Impact: Passwords can enter debug/error logs during auth failures or debug 
tracing.
-- Recommendation: Redact sensitive request fields before logging, including 
password-like keys and auth headers.
-- Test note: Added a `FIXME:` in `test_auth_routing.py` for auth request 
body/redaction coverage.
+- Fix: `HGraphSession.request()` and `ResponseValidation` now redact 
password/token/secret/auth-like fields before logging request kwargs or error 
request bodies.
+- Test note: Added contract coverage proving auth request passwords do not 
appear in debug/error logs.
 
 ### CS-003: Absolute API paths drop configured URL path prefixes
 
@@ -45,7 +45,7 @@ Issue IDs use `CS-NNN`. Priorities are P0 highest through P5 
lowest.
 - Module: `hugegraph-python-client`
 - Layer: transport / routing
 - Paths: `hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py:100`, 
`hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py:120`, 
`hugegraph-python-client/src/pyhugegraph/api/auth.py:37`, 
`hugegraph-python-client/src/pyhugegraph/api/auth.py:84`
-- Status: open
+- Status: fixed
 - Evidence: `HGraphSession.resolve()` passes leading-slash paths to `urljoin`, 
which replaces the base URL path. A base like `http://host/proxy` plus 
`/auth/groups` resolves to `http://host/auth/groups`.
 - Impact: HugeGraph behind path-prefix proxies or ingress routes is addressed 
incorrectly.
 - Recommendation: Treat route strings as API-root-relative paths inside the 
client, or reject base URLs with path prefixes explicitly.
@@ -60,8 +60,8 @@ Issue IDs use `CS-NNN`. Priorities are P0 highest through P5 
lowest.
 - Status: open
 - Evidence: `ResponseValidation.__call__()` catches broad `Exception` after 
successful HTTP parsing and returns the default `{}`. A `200 OK` response with 
malformed JSON becomes an empty dict.
 - Impact: Protocol drift or corrupt responses are indistinguishable from 
legitimate empty results.
-- Recommendation: Raise a typed parse/protocol exception on 
successful-response parse errors.
-- Test note: Existing response validation tests do not cover `2xx` malformed 
JSON.
+- Fix: successful-response parse failures now raise `ResponseParseError` 
instead of returning `{}`.
+- Test note: Added malformed `2xx` JSON coverage in 
`test_response_validation.py`.
 
 ### CS-005: Graphspace support depends on one fragile constructor-time probe
 
@@ -69,7 +69,7 @@ Issue IDs use `CS-NNN`. Priorities are P0 highest through P5 
lowest.
 - Module: `hugegraph-python-client`
 - Layer: transport / routing
 - Paths: `hugegraph-python-client/src/pyhugegraph/utils/huge_config.py:44`, 
`hugegraph-python-client/src/pyhugegraph/utils/huge_config.py:73`, 
`hugegraph-python-client/src/pyhugegraph/utils/huge_router.py:139`
-- Status: open
+- Status: fixed
 - Evidence: `HGraphConfig.__post_init__()` probes `/versions` with a fixed 
`0.5s` timeout. Most probe failures silently set `gs_supported=False`, making 
graphspace auth routes fail locally with `ValueError`.
 - Impact: HugeGraph 1.7+ auth behavior becomes sensitive to a single transient 
version probe.
 - Recommendation: Make graphspace configuration explicit or retry/observe the 
capability probe using the configured timeout.
@@ -108,8 +108,8 @@ Issue IDs use `CS-NNN`. Priorities are P0 highest through 
P5 lowest.
 - Status: open
 - Evidence: `GremlinManager.exec()` catches every exception and raises 
`NotFoundError`.
 - Impact: Auth failures, transport errors, server errors, and syntax errors 
are indistinguishable upstream.
-- Recommendation: Preserve typed lower-level exceptions; map only known 
Gremlin business errors.
-- Test note: Added a `FIXME:` in `test_gremlin.py`.
+- Fix: `GremlinManager.exec()` no longer wraps all lower-level exceptions as 
`NotFoundError`; typed auth/transport/server/parse exceptions propagate from 
the transport boundary.
+- Test note: Added unit coverage proving `NotAuthorizedError` is preserved 
through `GremlinManager.exec()`.
 
 ### CS-009: Config endpoints collapse all failures into `Missing Value`
 
diff --git a/.workflow/code-scan/reports/test-quality-ledger.md 
b/.workflow/code-scan/reports/test-quality-ledger.md
index 07614020..c9db6eb0 100644
--- a/.workflow/code-scan/reports/test-quality-ledger.md
+++ b/.workflow/code-scan/reports/test-quality-ledger.md
@@ -36,8 +36,6 @@ Added specific `FIXME:` comments in these files:
 ## Missing Coverage Around Core Functions
 
 - `HGraphSession.resolve()` with base URL path prefixes.
-- `ResponseValidation` success-response malformed JSON.
-- `GremlinManager.exec()` typed error propagation.
 - `GraphManager` query filter URL encoding.
 - `PropertyKey` advanced builder payload.
 - `IndexLabel` multi-field order.
@@ -58,3 +56,6 @@ Added specific `FIXME:` comments in these files:
 ## Resolved Coverage Gaps
 
 - `CS-001`: `hugegraph-llm/src/tests/api/test_admin_api.py` now covers admin 
log traversal rejection, absolute-path rejection, insecure default-token 
rejection, and valid log streaming.
+- `CS-002`: client transport and response-validation tests now prove 
password-like request fields are redacted from debug/error logs.
+- `CS-004`: response-validation tests now prove malformed successful JSON 
raises `ResponseParseError`.
+- `CS-008`: Gremlin tests now prove lower-level auth exceptions keep their 
original type through `GremlinManager.exec()`.
diff --git a/hugegraph-python-client/src/pyhugegraph/api/gremlin.py 
b/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
index 7d0b8af1..d738410c 100644
--- a/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
+++ b/hugegraph-python-client/src/pyhugegraph/api/gremlin.py
@@ -20,7 +20,6 @@ from pyhugegraph.api.common import HugeParamsBase
 from pyhugegraph.structure.gremlin_data import GremlinData
 from pyhugegraph.structure.response_data import ResponseData
 from pyhugegraph.utils import huge_router as router
-from pyhugegraph.utils.exceptions import NotFoundError
 from pyhugegraph.utils.log import log
 
 
@@ -45,10 +44,7 @@ class GremlinManager(HugeParamsBase):
                 "g": f"__g_{self._sess.cfg.graph_name}",
             }
 
-        try:
-            if response := self._invoke_request(data=gremlin_data.to_json()):
-                return ResponseData(response).result
-            log.error("Gremlin can't get results: %s", str(response))
-            return None
-        except Exception as e:
-            raise NotFoundError(f"Gremlin can't get results: {e}") from e
+        if response := self._invoke_request(data=gremlin_data.to_json()):
+            return ResponseData(response).result
+        log.error("Gremlin can't get results: %s", str(response))
+        return None
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/exceptions.py 
b/hugegraph-python-client/src/pyhugegraph/utils/exceptions.py
index f535d864..a8e07b10 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/exceptions.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/exceptions.py
@@ -1,64 +1,76 @@
-# 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.
-
-
-class NotAuthorizedError(Exception):
-    """
-    Not Authorized
-    """
-
-
-class InvalidParameterError(Exception):
-    """
-    Parameter setting error
-    """
-
-
-class NotFoundError(Exception):
-    """
-    no content found
-    """
-
-
-class CreateError(Exception):
-    """
-    Failed to create vertex or edge
-    """
-
-
-class RemoveError(Exception):
-    """
-    Failed to delete vertex or edge
-    """
-
-
-class UpdateError(Exception):
-    """
-    Failed to modify node
-    """
-
-
-class DataFormatError(Exception):
-    """
-    Input data format error
-    """
-
-
-class ServiceUnavailableError(Exception):
-    """
-    The server is too busy to be available
-    """
+# 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.
+
+
+class NotAuthorizedError(Exception):
+    """
+    Not Authorized
+    """
+
+
+class InvalidParameterError(Exception):
+    """
+    Parameter setting error
+    """
+
+
+class NotFoundError(Exception):
+    """
+    no content found
+    """
+
+
+class CreateError(Exception):
+    """
+    Failed to create vertex or edge
+    """
+
+
+class RemoveError(Exception):
+    """
+    Failed to delete vertex or edge
+    """
+
+
+class UpdateError(Exception):
+    """
+    Failed to modify node
+    """
+
+
+class DataFormatError(Exception):
+    """
+    Input data format error
+    """
+
+
+class ServiceUnavailableError(Exception):
+    """
+    The server is too busy to be available
+    """
+
+
+class ResponseParseError(Exception):
+    """
+    Failed to parse a successful server response
+    """
+
+
+class ServerError(Exception):
+    """
+    Server returned an error response
+    """
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py 
b/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
index 4ced85db..8fb933df 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/huge_requests.py
@@ -1,155 +1,155 @@
-# 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.
-
-
-from typing import Any
-from urllib.parse import urljoin
-
-import requests
-from requests.adapters import HTTPAdapter
-from urllib3.util.retry import Retry
-
-from pyhugegraph.utils.constants import Constants
-from pyhugegraph.utils.huge_config import HGraphConfig
-from pyhugegraph.utils.log import log
-from pyhugegraph.utils.util import ResponseValidation
-
-
-class HGraphSession:
-    def __init__(
-        self,
-        cfg: HGraphConfig,
-        retries: int = 3,
-        backoff_factor: int = 0.1,
-        status_forcelist=(500, 502, 504),
-        session: requests.Session | None = None,
-    ):
-        """
-        Initialize the HGraphSession object.
-        :param retries: The maximum number of retries.
-        :param backoff_factor: The backoff factor, used to calculate the 
interval between retries.
-        :param status_forcelist: A list of status codes that trigger a retry.
-        :param session: An optional requests.Session instance, for testing or 
advanced use cases.
-        """
-        self._cfg = cfg
-        self._retries = retries
-        self._backoff_factor = backoff_factor
-        self._status_forcelist = status_forcelist
-        self._auth = (cfg.username, cfg.password)
-        self._headers = {"Content-Type": Constants.HEADER_CONTENT_TYPE}
-        self._timeout = cfg.timeout
-        self._session = session if session else requests.Session()
-        self.__configure_session()
-
-    def __configure_session(self):
-        """
-        Configure the retry strategy and connection adapter for the session.
-        """
-        retry_strategy = Retry(
-            total=self._retries,
-            read=self._retries,
-            connect=self._retries,
-            backoff_factor=self._backoff_factor,
-            status_forcelist=self._status_forcelist,
-        )
-        adapter = HTTPAdapter(max_retries=retry_strategy)
-        self._session.mount("http://";, adapter)
-        self._session.mount("https://";, adapter)
-        self._session.keep_alive = False
-        log.debug(
-            "Session configured with retries=%s and backoff_factor=%s",
-            self._retries,
-            self._backoff_factor,
-        )
-
-    @property
-    def cfg(self):
-        """
-        Get the configuration information of the current instance.
-
-        Args:
-            None.
-
-        Returns:
-        -------
-            HGraphConfig: The configuration information of the current 
instance.
-        """
-        return self._cfg
-
-    def resolve(self, path: str):
-        """
-        Constructs the full URL for the given pathinfo based on the session 
context and API version.
-
-        :param path: The pathinfo to be appended to the base URL.
-        :return: The fully resolved URL as a string.
-
-        When path is "/some/things":
-        - Since path starts with "/", it is considered an absolute path,
-          and urljoin will replace the path part of the base URL.
-        - Assuming the base URL is 
"http://127.0.0.1:8000/graphspaces/default/graphs/test_graph/";
-        - The result will be "http://127.0.0.1:8000/some/things";
-
-        When path is "some/things":
-        - Since path is a relative path, urljoin will append it to the path 
part of the base URL.
-        - Assuming the base URL is 
"http://127.0.0.1:8000/graphspaces/default/graphs/test_graph/";
-        - The result will be 
"http://127.0.0.1:8000/graphspaces/default/graphs/test_graph/some/things";
-        """
-
-        url = f"{self._cfg.url}/"
-        if self._cfg.gs_supported:
-            url = urljoin(
-                url,
-                
f"graphspaces/{self._cfg.graphspace}/graphs/{self._cfg.graph_name}/",
-            )
-        else:
-            url = urljoin(url, f"graphs/{self._cfg.graph_name}/")
-        return urljoin(url, path).strip("/")
-
-    def close(self):
-        """
-        closes the session.
-
-        Args:
-            None
-
-        Returns:
-            None
-
-        """
-        self._session.close()
-
-    def request(
-        self,
-        path: str,
-        method: str = "GET",
-        validator=None,
-        **kwargs: Any,
-    ) -> dict:
-        if validator is None:
-            validator = ResponseValidation()
-        url = self.resolve(path)
-        response: requests.Response = getattr(self._session, method.lower())(
-            url,
-            auth=self._auth,
-            headers=self._headers,
-            timeout=self._timeout,
-            **kwargs,
-        )
-        log.debug(  # pylint: disable=logging-fstring-interpolation
-            f"Request: {method} {url} validator={validator} kwargs={kwargs} 
{response}"
-        )
-        return validator(response, method=method, path=path)
+# 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.
+
+
+from typing import Any
+from urllib.parse import urljoin
+
+import requests
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+from pyhugegraph.utils.constants import Constants
+from pyhugegraph.utils.huge_config import HGraphConfig
+from pyhugegraph.utils.log import log
+from pyhugegraph.utils.util import ResponseValidation, redact_sensitive_data
+
+
+class HGraphSession:
+    def __init__(
+        self,
+        cfg: HGraphConfig,
+        retries: int = 3,
+        backoff_factor: int = 0.1,
+        status_forcelist=(500, 502, 504),
+        session: requests.Session | None = None,
+    ):
+        """
+        Initialize the HGraphSession object.
+        :param retries: The maximum number of retries.
+        :param backoff_factor: The backoff factor, used to calculate the 
interval between retries.
+        :param status_forcelist: A list of status codes that trigger a retry.
+        :param session: An optional requests.Session instance, for testing or 
advanced use cases.
+        """
+        self._cfg = cfg
+        self._retries = retries
+        self._backoff_factor = backoff_factor
+        self._status_forcelist = status_forcelist
+        self._auth = (cfg.username, cfg.password)
+        self._headers = {"Content-Type": Constants.HEADER_CONTENT_TYPE}
+        self._timeout = cfg.timeout
+        self._session = session if session else requests.Session()
+        self.__configure_session()
+
+    def __configure_session(self):
+        """
+        Configure the retry strategy and connection adapter for the session.
+        """
+        retry_strategy = Retry(
+            total=self._retries,
+            read=self._retries,
+            connect=self._retries,
+            backoff_factor=self._backoff_factor,
+            status_forcelist=self._status_forcelist,
+        )
+        adapter = HTTPAdapter(max_retries=retry_strategy)
+        self._session.mount("http://";, adapter)
+        self._session.mount("https://";, adapter)
+        self._session.keep_alive = False
+        log.debug(
+            "Session configured with retries=%s and backoff_factor=%s",
+            self._retries,
+            self._backoff_factor,
+        )
+
+    @property
+    def cfg(self):
+        """
+        Get the configuration information of the current instance.
+
+        Args:
+            None.
+
+        Returns:
+        -------
+            HGraphConfig: The configuration information of the current 
instance.
+        """
+        return self._cfg
+
+    def resolve(self, path: str):
+        """
+        Constructs the full URL for the given pathinfo based on the session 
context and API version.
+
+        :param path: The pathinfo to be appended to the base URL.
+        :return: The fully resolved URL as a string.
+
+        When path is "/some/things":
+        - Since path starts with "/", it is considered an absolute path,
+          and urljoin will replace the path part of the base URL.
+        - Assuming the base URL is 
"http://127.0.0.1:8000/graphspaces/default/graphs/test_graph/";
+        - The result will be "http://127.0.0.1:8000/some/things";
+
+        When path is "some/things":
+        - Since path is a relative path, urljoin will append it to the path 
part of the base URL.
+        - Assuming the base URL is 
"http://127.0.0.1:8000/graphspaces/default/graphs/test_graph/";
+        - The result will be 
"http://127.0.0.1:8000/graphspaces/default/graphs/test_graph/some/things";
+        """
+
+        url = f"{self._cfg.url}/"
+        if self._cfg.gs_supported:
+            url = urljoin(
+                url,
+                
f"graphspaces/{self._cfg.graphspace}/graphs/{self._cfg.graph_name}/",
+            )
+        else:
+            url = urljoin(url, f"graphs/{self._cfg.graph_name}/")
+        return urljoin(url, path).strip("/")
+
+    def close(self):
+        """
+        closes the session.
+
+        Args:
+            None
+
+        Returns:
+            None
+
+        """
+        self._session.close()
+
+    def request(
+        self,
+        path: str,
+        method: str = "GET",
+        validator=None,
+        **kwargs: Any,
+    ) -> dict:
+        if validator is None:
+            validator = ResponseValidation()
+        url = self.resolve(path)
+        response: requests.Response = getattr(self._session, method.lower())(
+            url,
+            auth=self._auth,
+            headers=self._headers,
+            timeout=self._timeout,
+            **kwargs,
+        )
+        log.debug(  # pylint: disable=logging-fstring-interpolation
+            f"Request: {method} {url} validator={validator} 
kwargs={redact_sensitive_data(kwargs)} {response}"
+        )
+        return validator(response, method=method, path=path)
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/util.py 
b/hugegraph-python-client/src/pyhugegraph/utils/util.py
index c1b806cb..4d509cae 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/util.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/util.py
@@ -1,142 +1,193 @@
-# 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:
-                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
-                if response.status_code >= 400:
-                    raise Exception(f"Server Exception: {details}") from e
-                raise 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:
+                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 --git a/hugegraph-python-client/src/tests/api/test_auth_routing.py 
b/hugegraph-python-client/src/tests/api/test_auth_routing.py
index 5ee0a40b..ebaf6fba 100644
--- a/hugegraph-python-client/src/tests/api/test_auth_routing.py
+++ b/hugegraph-python-client/src/tests/api/test_auth_routing.py
@@ -15,15 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from unittest import mock
 from urllib.parse import urljoin
 
 import pytest
+import requests
 from pyhugegraph.api.auth import AuthManager
+from pyhugegraph.utils.huge_requests import HGraphSession
 
 pytestmark = pytest.mark.contract
 
-# FIXME: cover real HGraphSession.resolve() with URL prefixes and auth request
-# bodies; this DummySession duplicates production routing behavior.
+# FIXME: cover real HGraphSession.resolve() with URL prefixes; this
+# DummySession duplicates production routing behavior.
 
 
 class DummyCfg:
@@ -115,3 +118,28 @@ def test_groups_are_server_level():
     auth2 = AuthManager(sess2)
     auth2.list_groups()
     assert "auth/groups" in sess2.last
+
+
+def test_session_debug_log_redacts_sensitive_kwargs():
+    cfg = DummyCfg(url="http://127.0.0.1:8080";, graphspace=None, 
gs_supported=False, graph_name="g")
+    cfg.username = "admin"
+    cfg.password = "admin-password"
+    cfg.timeout = 30
+    response = mock.Mock(spec=requests.Response)
+    response.status_code = 200
+    response.json.return_value = {"ok": True}
+    response.raise_for_status.return_value = None
+    raw_session = mock.Mock()
+    raw_session.post.return_value = response
+    session = HGraphSession(cfg, session=raw_session)
+
+    with mock.patch("pyhugegraph.utils.huge_requests.log.debug") as log_debug:
+        session.request(
+            "/auth/users",
+            method="POST",
+            data='{"user_name":"marko","user_password":"super-secret"}',
+        )
+
+    logged_args = str(log_debug.call_args)
+    assert "super-secret" not in logged_args
+    assert "***REDACTED***" in logged_args
diff --git a/hugegraph-python-client/src/tests/api/test_gremlin.py 
b/hugegraph-python-client/src/tests/api/test_gremlin.py
index 9705caaa..46c297a8 100644
--- a/hugegraph-python-client/src/tests/api/test_gremlin.py
+++ b/hugegraph-python-client/src/tests/api/test_gremlin.py
@@ -20,14 +20,13 @@ import unittest
 from unittest import mock
 
 import pytest
-from pyhugegraph.utils.exceptions import NotFoundError
+from pyhugegraph.api.gremlin import GremlinManager
+from pyhugegraph.utils.exceptions import NotAuthorizedError, ServerError
 
 from ..client_utils import ClientUtils
 
 pytestmark = [pytest.mark.integration, pytest.mark.hugegraph]
 
-# FIXME: assert auth/transport/5xx failures keep their original exception
-# types; current tests only exercise query errors mapped to NotFoundError.
 # FIXME: clear graph state per test case; setUp() repopulates fixed primary-key
 # fixtures and currently depends on prior tests to clean up.
 
@@ -102,11 +101,11 @@ class TestGremlin(unittest.TestCase):
         self.assertEqual(0, len(lst))
 
     def test_invalid_gremlin(self):
-        with pytest.raises(NotFoundError):
+        with pytest.raises(ServerError):
             self.assertTrue(self.gremlin.exec("g.V2()"))
 
     def test_security_operation(self):
-        with pytest.raises(NotFoundError):
+        with pytest.raises(ServerError):
             self.assertTrue(self.gremlin.exec("System.exit(-1)"))
 
 
@@ -144,8 +143,27 @@ class TestGremlinSetupBehavior(unittest.TestCase):
 
 
 def test_gremlin_error_surface_is_explicit(client_utils):
-    with pytest.raises(NotFoundError) as exc_info:
+    with pytest.raises(ServerError) as exc_info:
         client_utils.gremlin.exec("g.V2()")
 
     message = str(exc_info.value)
     assert "g.V2" in message or "No signature" in message or "NotFound" in 
message
+
+
+class _FailingGremlinSession:
+    class Cfg:
+        gs_supported = False
+        graph_name = "hugegraph"
+        graphspace = None
+
+    cfg = Cfg()
+
+    def request(self, *_args, **_kwargs):
+        raise NotAuthorizedError("bad credentials")
+
+
+def test_gremlin_exec_preserves_auth_exception_type():
+    gremlin = GremlinManager(_FailingGremlinSession())
+
+    with pytest.raises(NotAuthorizedError, match="bad credentials"):
+        gremlin.exec("g.V()")
diff --git a/hugegraph-python-client/src/tests/api/test_response_validation.py 
b/hugegraph-python-client/src/tests/api/test_response_validation.py
index c66a2086..0653db55 100644
--- a/hugegraph-python-client/src/tests/api/test_response_validation.py
+++ b/hugegraph-python-client/src/tests/api/test_response_validation.py
@@ -20,6 +20,7 @@ from unittest.mock import Mock
 
 import pytest
 import requests
+from pyhugegraph.utils.exceptions import ResponseParseError, ServerError
 from pyhugegraph.utils.util import ResponseValidation
 
 pytestmark = pytest.mark.contract
@@ -77,6 +78,33 @@ class TestResponseValidation(unittest.TestCase):
         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
+
 
 if __name__ == "__main__":
     unittest.main()


Reply via email to