This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 b63f010793 [#13736] fix(python-client): make close idempotent (#13741)
b63f010793 is described below
commit b63f010793bbf8e37a3be40915c89e4cbc7f8dc7
Author: Thunderkeg <[email protected]>
AuthorDate: Sat Oct 10 17:37:52 2026 +0800
[#13736] fix(python-client): make close idempotent (#13741)
### What changes were proposed in this pull request?
- Remove the unsupported HTTP `CLOSE` request from `HTTPClient.close()`.
- Make close local and idempotent after success: call the authentication
data provider without an HTTP request, skip cleanup on repeated
successful calls, and propagate failures so callers can retry.
- Add regression tests for `HTTPClient`, the public `GravitinoClient`,
and `OAuth2TokenProvider` delegation.
### Why are the changes needed?
The bug is that `HTTPClient.close()` sends an unsupported `CLOSE /`
request before closing the authentication provider. The request can fail
when the server does not implement `CLOSE` or is unavailable, preventing
local authentication cleanup.
Fix: #13736
### Does this PR introduce any user-facing change?
Yes. Python client `close()` no longer sends an HTTP request and is safe
to call repeatedly after successful cleanup. The public method signature
is unchanged.
### How was this patch tested?
- From `clients/client-python/tests/unittests`, `PYTHONPATH=../..
coverage run --branch -m unittest` passed 1,185 tests.
- From the same directory, `PYTHONPATH=../.. python -m unittest -v
auth.test_oauth2_token_provider.TestOAuth2TokenProvider.test_close_delegates_without_request`
passed.
- Ruff and Black checks passed on the four changed Python files.
- A local Docker regression against a `2.0.0-SNAPSHOT` server verified
successful `GET /api/version` responses, no `CLOSE` requests, and one
provider cleanup when the server was available and after it stopped.
- Full OAuth service integration is not covered.
---------
Signed-off-by: jiangxt2 <[email protected]>
---
.../client-python/gravitino/utils/http_client.py | 7 ++-
.../unittests/auth/test_oauth2_token_provider.py | 23 +++++++++
.../tests/unittests/test_gravitino_client.py | 17 +++++++
.../tests/unittests/test_http_client_close.py | 58 ++++++++++++++++++++++
4 files changed, 104 insertions(+), 1 deletion(-)
diff --git a/clients/client-python/gravitino/utils/http_client.py
b/clients/client-python/gravitino/utils/http_client.py
index 0aaa0e3940..c7fece2bd5 100644
--- a/clients/client-python/gravitino/utils/http_client.py
+++ b/clients/client-python/gravitino/utils/http_client.py
@@ -97,6 +97,7 @@ class HTTPClient:
self.timeout = gravitino_client_config.get_client_request_timeout()
self.is_debug = is_debug
self.auth_data_provider = auth_data_provider
+ self._closed = False
def _build_url(self, endpoint=None, params=None):
url = self.host
@@ -244,10 +245,14 @@ class HTTPClient:
)
def close(self):
- self._request("close", "/")
+ if self._closed:
+ return
+
if self.auth_data_provider is not None:
self.auth_data_provider.close()
+ self._closed = True
+
def unpack(path: str):
def decorator(func):
diff --git
a/clients/client-python/tests/unittests/auth/test_oauth2_token_provider.py
b/clients/client-python/tests/unittests/auth/test_oauth2_token_provider.py
index d7c3928d05..ddf998c011 100644
--- a/clients/client-python/tests/unittests/auth/test_oauth2_token_provider.py
+++ b/clients/client-python/tests/unittests/auth/test_oauth2_token_provider.py
@@ -20,17 +20,40 @@ from unittest.mock import patch
from gravitino.auth.auth_constants import AuthConstants
from gravitino.auth.default_oauth2_token_provider import
DefaultOAuth2TokenProvider
+from gravitino.auth.oauth2_token_provider import OAuth2TokenProvider
from gravitino.exceptions.base import (
BadRequestException,
IllegalArgumentException,
UnauthorizedException,
)
+from gravitino.utils.http_client import HTTPClient
from tests.unittests.auth import mock_base
OAUTH_PORT = 1082
+class _TestOAuth2TokenProvider(OAuth2TokenProvider):
+ def _get_access_token(self):
+ return None
+
+ def validate(self):
+ pass
+
+
class TestOAuth2TokenProvider(unittest.TestCase):
+ @patch("gravitino.utils.http_client.build_opener")
+ def test_close_delegates_without_request(self, build_opener):
+ provider = _TestOAuth2TokenProvider("http://localhost")
+
+ with patch.object(
+ HTTPClient, "close", autospec=True, side_effect=HTTPClient.close
+ ) as client_close:
+ provider.close()
+ provider.close()
+
+ self.assertEqual(2, client_close.call_count)
+ build_opener.assert_not_called()
+
def test_provider_init_exception(self):
with self.assertRaises(IllegalArgumentException):
_ = DefaultOAuth2TokenProvider(uri="test")
diff --git a/clients/client-python/tests/unittests/test_gravitino_client.py
b/clients/client-python/tests/unittests/test_gravitino_client.py
index 80ebda8c16..9b3aad87da 100644
--- a/clients/client-python/tests/unittests/test_gravitino_client.py
+++ b/clients/client-python/tests/unittests/test_gravitino_client.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import unittest
+from unittest.mock import Mock, patch
from gravitino import GravitinoAdminClient, GravitinoClient
from gravitino.client.gravitino_client_config import GravitinoClientConfig
@@ -25,6 +26,22 @@ from tests.unittests import mock_base
@mock_base.mock_data
class TestMetalake(unittest.TestCase):
# pylint: disable=W0212
+ def test_gravitino_client_close_delegates_without_request(self,
*mock_methods):
+ auth_data_provider = Mock()
+ gravitino_client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name="test",
+ check_version=False,
+ auth_data_provider=auth_data_provider,
+ )
+
+ with patch("gravitino.utils.http_client.build_opener") as build_opener:
+ gravitino_client.close()
+ gravitino_client.close()
+
+ auth_data_provider.close.assert_called_once_with()
+ build_opener.assert_not_called()
+
def test_gravitino_client_headers(self, *mock_methods):
expected_headers = {
"k1": "v1",
diff --git a/clients/client-python/tests/unittests/test_http_client_close.py
b/clients/client-python/tests/unittests/test_http_client_close.py
new file mode 100644
index 0000000000..9f1252409e
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_http_client_close.py
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest
+from unittest.mock import Mock, patch
+
+from gravitino.utils.http_client import HTTPClient
+
+
+class TestHTTPClientClose(unittest.TestCase):
+ @patch("gravitino.utils.http_client.build_opener")
+ def test_close_without_auth_provider_is_idempotent(self, build_opener):
+ client = HTTPClient("http://localhost")
+
+ client.close()
+ client.close()
+
+ build_opener.assert_not_called()
+
+ @patch("gravitino.utils.http_client.build_opener")
+ def test_close_closes_auth_provider_once(self, build_opener):
+ auth_data_provider = Mock()
+ client = HTTPClient("http://localhost",
auth_data_provider=auth_data_provider)
+
+ client.close()
+ client.close()
+
+ auth_data_provider.close.assert_called_once_with()
+ build_opener.assert_not_called()
+
+ @patch("gravitino.utils.http_client.build_opener")
+ def test_close_retries_after_auth_provider_failure(self, build_opener):
+ auth_data_provider = Mock()
+ auth_data_provider.close.side_effect = [RuntimeError("cleanup
failed"), None]
+ client = HTTPClient("http://localhost",
auth_data_provider=auth_data_provider)
+
+ with self.assertRaisesRegex(RuntimeError, "cleanup failed"):
+ client.close()
+
+ client.close()
+ client.close()
+
+ self.assertEqual(2, auth_data_provider.close.call_count)
+ build_opener.assert_not_called()