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

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 5460487c49 [#14337] fix(client-python): Preserve HTTP error context 
(#14366)
5460487c49 is described below

commit 5460487c4906d3b86baa4e25d82e931a410a5633
Author: Thunderkeg <[email protected]>
AuthorDate: Wed Nov 4 20:02:24 2026 +0800

    [#14337] fix(client-python): Preserve HTTP error context (#14366)
    
    ### What changes were proposed in this pull request?
    
    Keep valid Gravitino and OAuth2 domain errors and add a safe
    `HTTPResponseException` fallback for empty, unreadable or malformed HTTP
    error responses, including excessive JSON nesting. Close error
    responses, make the exception reconstructible across processes,
    construct DTOs from validated public fields, and document the behavior.
    
    ### Why are the changes needed?
    
    A gateway returning HTTP 502 with HTML can expose a JSON decoding error
    instead of a useful HTTP failure. Error diagnostics should retain safe
    HTTP context, and DTO aliases must not override validated protocol
    fields.
    
    Fixes #14337.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Fallback failures raise `HTTPResponseException`, a subclass of
    `RESTException`, with `status_code`, `request_method` and `endpoint`
    attributes. The method is the original client request method; the
    endpoint contains only its scheme, hostname and optional port.
    
    Fallback diagnostics omit credentials, URL paths, query parameters,
    fragments, request data and raw response data. Their default traceback
    suppresses underlying HTTP and decoding exception chains. The exception
    preserves its type, message and HTTP context across pickling and
    standard process-pool propagation.
    
    Empty or invalid responses bypass custom error handlers. Valid
    structured errors retain their domain exceptions; unknown fields,
    including internal DTO aliases, are ignored. Retry and authentication
    behavior remain unchanged.
    
    ### How was this patch tested?
    
    With the development dependencies installed, prepare source tests using
    `PYTHONPATH=clients/client-python python
    clients/client-python/scripts/generate_version.py`. Run the following
    from the repository root unless another directory is specified.
    
    - Python 3.12.9: 1,271 unit tests passed with `PYTHONPATH=../.. python
    -m coverage run --branch -m unittest -v` from
    `clients/client-python/tests/unittests`, including pickle
    reconstruction, deeply nested JSON, field alias collisions, safe
    diagnostics and custom-handler boundaries.
    - Real HTTP regression tests: 3 tests passed with
    `PYTHONPATH=clients/client-python python -m unittest -v
    tests.integration.test_http_client_errors`, covering gateway responses,
    truncated bodies, read timeouts, recovery, structured Gravitino/OAuth2
    errors and exception propagation through a real `spawn` process pool.
    - Python 3.10.18: the pickle round-trip passed with
    `PYTHONPATH=clients/client-python python -m unittest -v
    
tests.unittests.test_http_client_errors.TestHTTPClientErrors.test_exception_pickle_round_trip`.
    - Black and Pylint using `clients/client-python/pylintrc` passed on the
    four changed Python files; `git diff --check` and `./gradlew rat`
    passed.
    
    ---------
    
    Signed-off-by: jiangxt2 <[email protected]>
---
 clients/client-python/gravitino/exceptions/base.py |  30 +++
 .../client-python/gravitino/utils/http_client.py   |  95 ++++++--
 .../tests/integration/test_http_client_errors.py   | 137 +++++++++++
 .../tests/unittests/test_http_client_errors.py     | 270 +++++++++++++++++++++
 docs/how-to-use-python-client.md                   |  17 ++
 5 files changed, 535 insertions(+), 14 deletions(-)

diff --git a/clients/client-python/gravitino/exceptions/base.py 
b/clients/client-python/gravitino/exceptions/base.py
index b9a01b3c79..5dadbc939c 100644
--- a/clients/client-python/gravitino/exceptions/base.py
+++ b/clients/client-python/gravitino/exceptions/base.py
@@ -30,6 +30,36 @@ class RESTException(RuntimeError):
         super().__init__(message % args)
 
 
+class HTTPResponseException(RESTException):
+    """An HTTP failure whose response cannot be used as a domain error.
+
+    Attributes:
+        status_code: The HTTP status received from the server.
+        request_method: The original client request's HTTP method.
+        endpoint: The request origin, without credentials, path or query 
parameters.
+    """
+
+    def __init__(
+        self, status_code: int, request_method: str, endpoint: str, reason: str
+    ):
+        """Create an error from safe context and a fixed diagnostic reason."""
+        self.status_code = status_code
+        self.request_method = request_method
+        self.endpoint = endpoint
+        self._reason = reason
+        super().__init__(
+            "HTTP %s for %s %s: %s", status_code, request_method, endpoint, 
reason
+        )
+
+    def __reduce__(self):
+        """Reconstruct the exception with its safe context and instance 
state."""
+        return (
+            type(self),
+            (self.status_code, self.request_method, self.endpoint, 
self._reason),
+            self.__dict__,
+        )
+
+
 class IllegalArgumentException(ValueError):
     """Base class for all exceptions thrown when arguments are invalid."""
 
diff --git a/clients/client-python/gravitino/utils/http_client.py 
b/clients/client-python/gravitino/utils/http_client.py
index b9cac49891..4a8c84bc2c 100644
--- a/clients/client-python/gravitino/utils/http_client.py
+++ b/clients/client-python/gravitino/utils/http_client.py
@@ -23,8 +23,9 @@ SOFTWARE.
 """
 
 from typing import Tuple
+from http.client import IncompleteRead
 from urllib.request import Request, build_opener
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlsplit
 
 from urllib.error import HTTPError
 import json as _json
@@ -36,7 +37,11 @@ from gravitino.typing import JSONType
 
 from gravitino.dto.responses.error_response import ErrorResponse
 from gravitino.dto.responses.oauth2_error_response import OAuth2ErrorResponse
-from gravitino.exceptions.base import RESTException, UnknownError
+from gravitino.exceptions.base import (
+    BadRequestException,
+    HTTPResponseException,
+    UnknownError,
+)
 from gravitino.exceptions.handlers.error_handler import ErrorHandler
 
 
@@ -134,26 +139,88 @@ class HTTPClient:
         try:
             return (True, Response(opener.open(request, timeout=timeout)))
         except HTTPError as err:
+            try:
+                return (False, self._read_error_response(err, request))
+            finally:
+                try:
+                    err.close()
+                except OSError:
+                    # Closing a failed response must not replace its HTTP 
failure.
+                    pass
+
+    def _read_error_response(self, err, request) -> ErrorResponse:
+        try:
             err_body = err.read()
+        except (IncompleteRead, OSError):
+            raise self._http_response_exception(
+                err, request, "Unable to read error response"
+            ) from None
 
-            if err_body is None or len(err_body) == 0:
-                return (
-                    False,
-                    ErrorResponse.generate_error_response(RESTException, 
err.reason),
-                )
+        if not err_body:
+            raise self._http_response_exception(
+                err, request, "Empty error response"
+            ) from None
 
-            err_resp = self._parse_error_response(err_body)
-            err_resp.validate()
+        try:
+            response = self._parse_error_response(err_body)
+            response.validate()
+        except (ValueError, TypeError, KeyError, BadRequestException, 
RecursionError):
+            raise self._http_response_exception(
+                err, request, "Invalid error response"
+            ) from None
+        return response
 
-            return (False, err_resp)
+    @staticmethod
+    def _http_response_exception(err, request, reason) -> 
HTTPResponseException:
+        endpoint = "<unknown endpoint>"
+        try:
+            address = urlsplit(request.full_url)
+            hostname = address.hostname
+            if address.scheme in ("http", "https") and hostname:
+                host = f"[{hostname}]" if ":" in hostname else hostname
+                port = address.port
+                endpoint = f"{address.scheme}://{host}"
+                if port is not None:
+                    endpoint = f"{endpoint}:{port}"
+        except ValueError:
+            pass
+        return HTTPResponseException(err.code, request.get_method(), endpoint, 
reason)
 
     def _parse_error_response(self, err_body: bytes) -> ErrorResponse:
         json_err_body = _json.loads(err_body)
-
+        if not isinstance(json_err_body, dict):
+            raise ValueError("Error response must be an object")
         if "code" in json_err_body:
-            return ErrorResponse.from_json(err_body, infer_missing=True)
-
-        return OAuth2ErrorResponse.from_json(err_body, infer_missing=True)
+            code = json_err_body["code"]
+            if not isinstance(code, int) or isinstance(code, bool) or code < 0:
+                raise ValueError("Invalid error code")
+            for name in ("type", "message"):
+                value = json_err_body.get(name)
+                if not isinstance(value, str) or not value.strip():
+                    raise ValueError("Invalid error field")
+            stack = json_err_body.get("stack")
+            if stack is not None and (
+                not isinstance(stack, list)
+                or any(not isinstance(item, str) for item in stack)
+            ):
+                raise ValueError("Invalid error stack")
+            # Construct from validated fields so internal aliases cannot 
override them.
+            return ErrorResponse(
+                code, json_err_body["type"], json_err_body["message"], stack
+            )
+        error = json_err_body.get("error")
+        description = json_err_body.get("error_description")
+        if not isinstance(error, str) or not error:
+            raise ValueError("Invalid OAuth2 error")
+        if description is not None and not isinstance(description, str):
+            raise ValueError("Invalid OAuth2 error description")
+        stack = json_err_body.get("stack")
+        if stack is not None and (
+            not isinstance(stack, list)
+            or any(not isinstance(item, str) for item in stack)
+        ):
+            raise ValueError("Invalid OAuth2 error stack")
+        return OAuth2ErrorResponse(None, error, description, stack)
 
     # pylint: disable=too-many-locals
     def _request(
diff --git a/clients/client-python/tests/integration/test_http_client_errors.py 
b/clients/client-python/tests/integration/test_http_client_errors.py
new file mode 100644
index 0000000000..981e379b12
--- /dev/null
+++ b/clients/client-python/tests/integration/test_http_client_errors.py
@@ -0,0 +1,137 @@
+# 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 threading
+import traceback
+import unittest
+from concurrent.futures import ProcessPoolExecutor
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from multiprocessing import get_context
+from unittest.mock import patch
+
+from gravitino.exceptions.base import HTTPResponseException, 
UnauthorizedException
+from gravitino.exceptions.handlers.oauth_error_handler import 
OAUTH_ERROR_HANDLER
+from gravitino.exceptions.handlers.rest_error_handler import REST_ERROR_HANDLER
+from gravitino.utils.http_client import HTTPClient
+
+
+def _request_status(origin, endpoint):
+    return (
+        HTTPClient(origin)
+        .get(endpoint, timeout=2, error_handler=REST_ERROR_HANDLER)
+        .status_code
+    )
+
+
+class _ErrorServer(BaseHTTPRequestHandler):
+    def do_GET(self):  # pylint: disable=invalid-name
+        """Serve deliberately malformed responses and valid domain errors."""
+        if self.path == "/success":
+            self.send_response(200)
+            self.end_headers()
+            self.wfile.write(b'{"ok":true}')
+            return
+        payloads = {
+            "/html": b"<html>private-response</html>",
+            "/empty": b"",
+            "/text": b"private-response",
+            "/gravitino": 
b'{"code":1011,"type":"UnauthorizedException","message":"denied","stack":null}',
+            "/oauth": b'{"error":"invalid_client"}',
+        }
+        self.send_response(401 if self.path in ("/oauth", "/gravitino") else 
502)
+        # Valid JSON must work even when an intermediary mislabels its content 
type.
+        self.send_header("Content-Type", "text/html")
+        if self.path in ("/truncated", "/timeout"):
+            self.send_header("Content-Length", "100")
+        self.end_headers()
+        if self.path == "/timeout":
+            self.wfile.flush()
+            self.server.release.wait(5)
+        else:
+            self.wfile.write(payloads.get(self.path, b"private-partial"))
+
+    def log_message(self, format, *args):  # pylint: disable=redefined-builtin
+        """Keep fixture request logging out of test output."""
+
+
+class TestHTTPClientErrors(unittest.TestCase):
+    def setUp(self):
+        self.server = ThreadingHTTPServer(("127.0.0.1", 0), _ErrorServer)
+        self.server.release = threading.Event()
+        self.thread = threading.Thread(target=self.server.serve_forever)
+        self.thread.start()
+        self.addCleanup(self._stop_server)
+        self.environment = patch.dict(
+            "os.environ", {"no_proxy": "127.0.0.1", "NO_PROXY": "127.0.0.1"}
+        )
+        self.environment.start()
+        self.addCleanup(self.environment.stop)
+        self.origin = f"http://127.0.0.1:{self.server.server_port}";
+        self.client = HTTPClient(self.origin)
+
+    def _stop_server(self):
+        self.server.release.set()
+        self.server.shutdown()
+        self.server.server_close()
+        self.thread.join(5)
+        self.assertFalse(self.thread.is_alive())
+
+    def test_gateway_responses_and_recovery(self):
+        for endpoint in ("html", "empty", "text", "truncated", "timeout"):
+            with self.subTest(endpoint=endpoint):
+                self.server.release.clear()
+                with self.assertRaises(HTTPResponseException) as raised:
+                    self.client.get(
+                        endpoint, timeout=0.2, error_handler=REST_ERROR_HANDLER
+                    )
+                self.assertEqual(502, raised.exception.status_code)
+                self.assertEqual("GET", raised.exception.request_method)
+                self.assertEqual(self.origin, raised.exception.endpoint)
+                self.assertNotIn(
+                    "private-", 
"".join(traceback.format_exception(raised.exception))
+                )
+                if endpoint in ("truncated", "timeout"):
+                    self.assertIn("Unable to read", str(raised.exception))
+                self.server.release.set()
+                self.assertEqual({"ok": True}, 
self.client.get("success").json())
+
+    def test_error_propagation_keeps_process_pool_usable(self):
+        with ProcessPoolExecutor(
+            max_workers=1, mp_context=get_context("spawn")
+        ) as pool:
+            with self.assertRaises(HTTPResponseException) as raised:
+                pool.submit(_request_status, self.origin, 
"empty").result(timeout=10)
+            self.assertEqual(502, raised.exception.status_code)
+            self.assertEqual("GET", raised.exception.request_method)
+            self.assertEqual(self.origin, raised.exception.endpoint)
+            self.assertEqual(
+                f"HTTP 502 for GET {self.origin}: Empty error response",
+                str(raised.exception),
+            )
+            self.assertEqual(
+                200,
+                pool.submit(_request_status, self.origin, 
"success").result(timeout=10),
+            )
+
+    def test_domain_errors_with_mislabeled_content_type(self):
+        for endpoint, handler in (
+            ("gravitino", REST_ERROR_HANDLER),
+            ("oauth", OAUTH_ERROR_HANDLER),
+        ):
+            with self.subTest(endpoint=endpoint):
+                with self.assertRaises(UnauthorizedException):
+                    self.client.get(endpoint, error_handler=handler)
diff --git a/clients/client-python/tests/unittests/test_http_client_errors.py 
b/clients/client-python/tests/unittests/test_http_client_errors.py
new file mode 100644
index 0000000000..edfab5f551
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_http_client_errors.py
@@ -0,0 +1,270 @@
+# 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 pickle
+import traceback
+import unittest
+from http.client import IncompleteRead
+from io import BytesIO
+from unittest.mock import Mock, patch
+from urllib.error import HTTPError, URLError
+
+from gravitino.exceptions.base import (
+    BadRequestException,
+    HTTPResponseException,
+    RESTException,
+    UnauthorizedException,
+)
+from gravitino.exceptions.handlers.error_handler import ErrorHandler
+from gravitino.exceptions.handlers.oauth_error_handler import 
OAUTH_ERROR_HANDLER
+from gravitino.exceptions.handlers.rest_error_handler import REST_ERROR_HANDLER
+from gravitino.utils.http_client import HTTPClient
+
+
+class TestHTTPClientErrors(unittest.TestCase):
+    @staticmethod
+    def _error(body):
+        return HTTPError(
+            "https://user:[email protected]/private?secret=value";,
+            502,
+            "private-reason",
+            {"Set-Cookie": "private-cookie"},
+            BytesIO(body),
+        )
+
+    def _request(self, error, handler=REST_ERROR_HANDLER, method="get", 
host=None):
+        with patch("gravitino.utils.http_client.build_opener") as opener:
+            opener.return_value.open.side_effect = error
+            client = HTTPClient(host or 
"https://user:[email protected]:8443";)
+            return getattr(client, method)(
+                "private-path?secret=value#fragment",
+                headers={"Authorization": "Bearer private-token"},
+                error_handler=handler,
+            )
+
+    def test_invalid_responses_preserve_safe_context(self):
+        payloads = [
+            b"",
+            b"  ",
+            b"<html>private-body</html>",
+            b"private-body",
+            b"{",
+            b"\xff",
+            b"null",
+            b"[]",
+            b'"text"',
+            b"42",
+            b"{}",
+            b'{"code":1011}',
+            b'{"code":true,"type":"X","message":"X"}',
+            b'{"code":-1,"type":"X","message":"X"}',
+            b'{"code":"1011","type":"X","message":"X"}',
+            b'{"code":1011,"type":1,"message":"X"}',
+            b'{"code":1011,"type":"X","message":{}}',
+            b'{"code":1011,"type":"X","message":"X","stack":[1]}',
+            b'{"code":"1011","type":"X","message":"X","_code":1011}',
+            b'{"error":1}',
+            b'{"error":1,"_type":"invalid_client"}',
+            b'{"error":"invalid_client","error_description":[]}',
+            b'{"error":"unknown","stack":true}',
+            b"private-body" * 10000,
+            b"[" * 10000 + b"]" * 10000,
+        ]
+        for payload in payloads:
+            with self.subTest(payload=payload[:60]):
+                error = self._error(payload)
+                with self.assertRaises(HTTPResponseException) as raised:
+                    self._request(error)
+                exception = raised.exception
+                self.assertIsInstance(exception, RESTException)
+                self.assertEqual(502, exception.status_code)
+                self.assertEqual("GET", exception.request_method)
+                self.assertEqual("https://example.com:8443";, 
exception.endpoint)
+                diagnostic = "".join(traceback.format_exception(exception))
+                for private in (
+                    "password",
+                    "private-path",
+                    "secret=value",
+                    "fragment",
+                    "private-token",
+                    "private-body",
+                    "private-reason",
+                    "private-cookie",
+                    "JSONDecodeError",
+                    "RecursionError",
+                    "HTTPError:",
+                ):
+                    self.assertNotIn(private, diagnostic)
+                self.assertTrue(error.closed)
+
+    def test_exception_pickle_round_trip(self):
+        original = HTTPResponseException(
+            502, "GET", "https://example.com:8443";, "Empty error response"
+        )
+        original.detail = "safe context"
+        restored = pickle.loads(pickle.dumps(original))
+        self.assertIs(type(original), type(restored))
+        self.assertEqual(original.args, restored.args)
+        self.assertEqual(str(original), str(restored))
+        for name in ("status_code", "request_method", "endpoint", "detail"):
+            self.assertEqual(getattr(original, name), getattr(restored, name))
+
+    def test_read_failures_keep_http_status(self):
+        for failure in (
+            IncompleteRead(b"private-partial", 20),
+            OSError("private"),
+            TimeoutError("private"),
+        ):
+            with self.subTest(failure=type(failure).__name__):
+                error = self._error(b"")
+                error.read = Mock(side_effect=failure)
+                with self.assertRaises(HTTPResponseException) as raised:
+                    self._request(error, method="post")
+                self.assertEqual(502, raised.exception.status_code)
+                self.assertEqual("POST", raised.exception.request_method)
+                self.assertIn("Unable to read", str(raised.exception))
+                self.assertNotIn(
+                    "private", 
"".join(traceback.format_exception(raised.exception))
+                )
+                self.assertTrue(error.closed)
+
+    def test_close_failure_does_not_replace_http_failure(self):
+        error = self._error(b"not-json")
+        original_close = error.close
+        error.close = Mock(side_effect=OSError("private-close-error"))
+        try:
+            with self.assertRaises(HTTPResponseException):
+                self._request(error)
+        finally:
+            original_close()
+
+    def test_transport_failures_are_not_converted(self):
+        for failure in (
+            URLError("unreachable"),
+            ConnectionError("disconnected"),
+            TimeoutError("timeout"),
+        ):
+            with self.subTest(failure=type(failure).__name__):
+                with self.assertRaises(type(failure)) as raised:
+                    self._request(failure)
+                self.assertIs(failure, raised.exception)
+
+    def test_gravitino_errors_keep_mapping_and_unknown_fields(self):
+        for code, expected in ((1011, UnauthorizedException), (1999, 
RESTException)):
+            body = json.dumps(
+                {
+                    "code": code,
+                    "type": "FutureException",
+                    "message": "failure",
+                    "stack": ["server stack"],
+                    "future": True,
+                }
+            ).encode()
+            with self.subTest(code=code):
+                with self.assertRaises(expected) as raised:
+                    self._request(self._error(body))
+                self.assertNotIsInstance(raised.exception, 
HTTPResponseException)
+                self.assertIn("failure\nserver stack", str(raised.exception))
+
+    def test_oauth2_errors_keep_mapping_and_optional_description(self):
+        for error, expected in (
+            ("invalid_client", UnauthorizedException),
+            ("invalid_grant", BadRequestException),
+            ("future_error", RESTException),
+        ):
+            for description in (None, "failure"):
+                body = {"error": error, "future": True}
+                if description is not None:
+                    body["error_description"] = description
+                with self.subTest(error=error, description=description):
+                    with self.assertRaises(expected) as raised:
+                        self._request(
+                            self._error(json.dumps(body).encode()), 
OAUTH_ERROR_HANDLER
+                        )
+                    self.assertNotIsInstance(raised.exception, 
HTTPResponseException)
+
+    def test_internal_aliases_cannot_override_public_fields(self):
+        for fields, delegate in (
+            ({"code": 1011, "type": "UnauthorizedException"}, 
REST_ERROR_HANDLER),
+            ({"error": "invalid_client"}, OAUTH_ERROR_HANDLER),
+        ):
+            fields.update(
+                message="public message", error_description="public message", 
stack=None
+            )
+            for code in (1999, float("inf")):
+                aliases = {
+                    "_code": code,
+                    "_type": "invalid_grant",
+                    "_message": 123,
+                    "_stack": [None],
+                }
+                for alias_first in (True, False):
+                    body = (
+                        {**aliases, **fields} if alias_first else {**fields, 
**aliases}
+                    )
+                    with self.subTest(
+                        fields=fields, code=code, alias_first=alias_first
+                    ):
+                        handler = Mock(spec=ErrorHandler, wraps=delegate)
+                        # A valid JSON exponent that decodes to infinity.
+                        payload = (
+                            json.dumps(body).encode().replace(b"Infinity", 
b"1e999")
+                        )
+                        error = self._error(payload)
+                        with self.assertRaises(UnauthorizedException):
+                            self._request(error, handler)
+                        response = handler.handle.call_args.args[0]
+                        self.assertEqual(fields.get("code"), response.code())
+                        self.assertEqual(
+                            fields.get("type", fields.get("error")), 
response.type()
+                        )
+                        self.assertEqual("public message", response.message())
+                        self.assertIsNone(response.stack())
+                        self.assertTrue(error.closed)
+
+    def test_handler_exception_is_not_converted(self):
+        body = (
+            
b'{"code":1999,"type":"FutureException","message":"failure","stack":null}'
+        )
+        for failure in (ValueError("custom failure"), RecursionError("custom 
failure")):
+            with self.subTest(failure=type(failure).__name__):
+                handler = Mock(spec=ErrorHandler)
+                handler.handle.side_effect = failure
+                with self.assertRaises(type(failure)) as raised:
+                    self._request(self._error(body), handler)
+                self.assertIs(failure, raised.exception)
+                handler.handle.assert_called_once()
+
+    def test_fallback_with_no_or_custom_handler(self):
+        handler = Mock(spec=ErrorHandler)
+        for value in (None, handler):
+            with self.subTest(handler=value):
+                with self.assertRaises(HTTPResponseException):
+                    self._request(self._error(b""), value)
+        handler.handle.assert_not_called()
+
+    def test_safe_endpoints(self):
+        for host, origin in (
+            ("http://[::1]:8090";, "http://[::1]:8090";),
+            ("https://user:[email protected]";, "https://example.com";),
+            ("https://example.com:invalid";, "<unknown endpoint>"),
+        ):
+            with self.subTest(host=host):
+                with self.assertRaises(HTTPResponseException) as raised:
+                    self._request(self._error(b""), host=host)
+                self.assertEqual(origin, raised.exception.endpoint)
diff --git a/docs/how-to-use-python-client.md b/docs/how-to-use-python-client.md
index c62e9a1071..6618439da0 100644
--- a/docs/how-to-use-python-client.md
+++ b/docs/how-to-use-python-client.md
@@ -55,6 +55,23 @@ extras.
 2. [Manage fileset metadata using Gravitino Python 
API](./manage-fileset-metadata-using-gravitino.md?language=python)
 3. [Manage Semantic Model metadata using Gravitino Python 
API](./manage-semantic-model-metadata-using-gravitino.md?language=python)
 
+### HTTP Error Handling
+
+Valid Gravitino and OAuth2 error responses retain their existing domain 
exceptions.
+When an HTTP error has an empty, unreadable or invalid error response, the 
client raises
+`gravitino.exceptions.base.HTTPResponseException`, a subclass of 
`RESTException`.
+Its `status_code`, `request_method` and `endpoint` attributes describe the 
HTTP status,
+the original client request method and its origin (scheme, hostname and 
optional port).
+Applications can catch `RESTException` to include these fallback failures.
+
+Fallback diagnostics omit URL credentials, paths, query parameters, fragments, 
request data,
+response headers and response bodies. The default traceback suppresses the 
underlying
+HTTP and decoding exception chain. Existing domain error messages are 
unchanged.
+This replaces the decoding or validation exceptions previously exposed for 
invalid HTTP
+error responses, and bypasses custom error handlers for empty or invalid error 
responses.
+Connection failures and timeouts before an HTTP error response retain their 
existing behavior.
+The client does not retry requests as part of this error handling.
+
 ### Semantic Model Ossie Import and Export
 
 Use `as_semantic_model_catalog()` on a loaded relational catalog to manage 
Semantic Models.

Reply via email to