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 654106b5ca [#13735] fix(client-python): isolate per-request headers
(#13743)
654106b5ca is described below
commit 654106b5cac5a8671066aa0956f0899c518e4dd6
Author: Thunderkeg <[email protected]>
AuthorDate: Fri Oct 16 02:45:28 2026 -0700
[#13735] fix(client-python): isolate per-request headers (#13743)
### What changes were proposed in this pull request?
Make HTTP request headers local to each request. The client now copies
configured headers and merges per-request headers into a request-local
map, preserving the existing override order and JSON/form content-type
behavior. Add unit and Fileset integration coverage for header isolation
after successful and failed requests.
### Why are the changes needed?
Per-request headers were being merged into the HTTP client's persistent
headers, so they could leak into later calls and mutate the
caller-provided header dictionary. This change keeps request-specific
context, including CallerContext headers, scoped to the request that
supplied it.
Fix: #13735
### Does this PR introduce _any_ user-facing change?
Yes. It fixes per-request headers so they no longer affect subsequent
calls made through the same client. There are no API additions or
removals.
### How was this patch tested?
- From `clients/client-python/tests/unittests`, `python -m coverage run
--branch -m unittest`: 1,183 tests passed under Python 3.12.12.
- From `clients/client-python/tests/integration`,
`START_EXTERNAL_GRAVITINO=true PROJECT_VERSION=2.0.0-SNAPSHOT python -m
unittest -v
test_fileset_catalog.TestFilesetCatalog.test_get_file_location`: 1 test
passed against the matching Docker server.
- `ruff check`, `ruff format --check`, and `git diff --check` passed.
- `./gradlew :clients:client-python:compileDistribution -PskipWeb=true
-x test` passed with Corretto 17.0.18.
Signed-off-by: jiangxt2 <[email protected]>
---
.../client-python/gravitino/utils/http_client.py | 18 +--
.../tests/integration/test_fileset_catalog.py | 26 ++++
.../tests/unittests/test_http_client_headers.py | 153 +++++++++++++++++++++
3 files changed, 189 insertions(+), 8 deletions(-)
diff --git a/clients/client-python/gravitino/utils/http_client.py
b/clients/client-python/gravitino/utils/http_client.py
index 1196e17ef9..a0caada82d 100644
--- a/clients/client-python/gravitino/utils/http_client.py
+++ b/clients/client-python/gravitino/utils/http_client.py
@@ -93,7 +93,7 @@ class HTTPClient:
client_config
)
self.host = host
- self.request_headers = request_headers or {}
+ self.request_headers = dict(request_headers or {})
self.timeout = gravitino_client_config.get_client_request_timeout()
self.is_debug = is_debug
self.auth_data_provider = auth_data_provider
@@ -113,7 +113,9 @@ class HTTPClient:
return url
def _update_headers(self, request_headers):
- self.request_headers.update(request_headers)
+ merged_headers = self.request_headers.copy()
+ merged_headers.update(request_headers)
+ return merged_headers
def _mask_auth_headers(self, headers):
if self.is_debug:
@@ -173,22 +175,22 @@ class HTTPClient:
self._update_headers(self.JSON_HEADER)
elif data:
request_data = urlencode(data.to_dict()).encode()
- self._update_headers(self.FORMDATA_HEADER)
+ request_headers = self._update_headers(self.FORMDATA_HEADER)
else:
if json:
request_data = json.to_json().encode("utf-8")
- self._update_headers(self.JSON_HEADER)
+ request_headers = self._update_headers(self.JSON_HEADER)
if headers:
- self._update_headers(headers)
+ request_headers.update(headers)
opener = build_opener()
request = Request(self._build_url(endpoint, params), data=request_data)
- if self.request_headers:
- for key, value in self.request_headers.items():
+ if request_headers:
+ for key, value in request_headers.items():
request.add_header(key, value)
- if request_data and ("Content-Type" not in self.request_headers):
+ if request_data and ("Content-Type" not in request_headers):
request.add_header("Content-Type", "application/json")
if self.auth_data_provider is not None:
request.add_header(
diff --git a/clients/client-python/tests/integration/test_fileset_catalog.py
b/clients/client-python/tests/integration/test_fileset_catalog.py
index 41e7fb35e5..b5f959d24d 100644
--- a/clients/client-python/tests/integration/test_fileset_catalog.py
+++ b/clients/client-python/tests/integration/test_fileset_catalog.py
@@ -350,12 +350,28 @@ class TestFilesetCatalog(IntegrationTestEnv):
"location2": "/tmp/test_get_file_location2",
}
self.create_custom_fileset(fileset_ident, None, locations, "location1")
+ caller_context = CallerContext(
+ {
+ FilesetAuditConstants.HTTP_HEADER_FILESET_DATA_OPERATION:
FilesetDataOperation.GET_FILE_STATUS.name
+ }
+ )
+ CallerContextHolder.set(caller_context)
actual_file_location = (
self.gravitino_client.load_catalog(name=self.catalog_name)
.as_fileset_catalog()
.get_file_location(fileset_ident, "/test/test.txt")
)
+ self.assertEqual(
+ actual_file_location,
f"file:{locations['location1']}/test/test.txt"
+ )
+ self.assertIsNone(CallerContextHolder.get())
+
+ actual_file_location = (
+ self.gravitino_client.load_catalog(name=self.catalog_name)
+ .as_fileset_catalog()
+ .get_file_location(fileset_ident, "/test/test.txt")
+ )
self.assertEqual(
actual_file_location,
f"file:{locations['location1']}/test/test.txt"
)
@@ -373,3 +389,13 @@ class TestFilesetCatalog(IntegrationTestEnv):
.as_fileset_catalog()
.get_file_location(fileset_ident, "")
)
+
+ self.assertIsNone(CallerContextHolder.get())
+ actual_file_location = (
+ self.gravitino_client.load_catalog(name=self.catalog_name)
+ .as_fileset_catalog()
+ .get_file_location(fileset_ident, "/test/test.txt")
+ )
+ self.assertEqual(
+ actual_file_location,
f"file:{locations['location1']}/test/test.txt"
+ )
diff --git a/clients/client-python/tests/unittests/test_http_client_headers.py
b/clients/client-python/tests/unittests/test_http_client_headers.py
new file mode 100644
index 0000000000..e5ace7862a
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_http_client_headers.py
@@ -0,0 +1,153 @@
+# 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 io import BytesIO
+from urllib.error import HTTPError
+from unittest.mock import Mock, patch
+
+from gravitino.exceptions.base import RESTException
+from gravitino.exceptions.handlers.rest_error_handler import REST_ERROR_HANDLER
+from gravitino.utils.http_client import HTTPClient
+
+
+class TestHTTPClientHeaders(unittest.TestCase):
+ @staticmethod
+ def _success_response():
+ response = Mock()
+ response.getcode.return_value = 200
+ response.read.return_value = b"{}"
+ response.info.return_value = {}
+ response.url = "http://localhost:8090/test"
+ return response
+
+ @staticmethod
+ def _capture_requests(mocked_build_opener, responses):
+ requests = []
+ response_iterator = iter(responses)
+
+ def open_request(request, timeout):
+ requests.append(request)
+ response = next(response_iterator)
+ if isinstance(response, Exception):
+ raise response
+ return response
+
+ mocked_build_opener.return_value.open.side_effect = open_request
+ return requests
+
+ @staticmethod
+ def _header_value(request, header_name):
+ for name, value in request.header_items():
+ if name.lower() == header_name.lower():
+ return value
+ return None
+
+ def test_request_headers_do_not_leak_between_calls(self):
+ configured_headers = {
+ "X-Configured": "configured",
+ "X-Override": "configured",
+ }
+ client = HTTPClient("http://localhost:8090",
request_headers=configured_headers)
+
+ with patch("gravitino.utils.http_client.build_opener") as build_opener:
+ requests = self._capture_requests(
+ build_opener, [self._success_response(),
self._success_response()]
+ )
+ client.get(
+ "/first",
+ headers={"X-Per-Request": "first", "X-Override": "request"},
+ )
+ client.get("/second")
+
+ self.assertEqual("configured", self._header_value(requests[0],
"X-Configured"))
+ self.assertEqual("request", self._header_value(requests[0],
"X-Override"))
+ self.assertEqual("first", self._header_value(requests[0],
"X-Per-Request"))
+ self.assertEqual("configured", self._header_value(requests[1],
"X-Configured"))
+ self.assertEqual("configured", self._header_value(requests[1],
"X-Override"))
+ self.assertIsNone(self._header_value(requests[1], "X-Per-Request"))
+ self.assertEqual(
+ {"X-Configured": "configured", "X-Override": "configured"},
+ configured_headers,
+ )
+ self.assertEqual(configured_headers, client.request_headers)
+
+ def test_json_and_form_headers_are_request_local(self):
+ configured_headers = {
+ "X-Configured": "configured",
+ "Content-Type": "configured/type",
+ }
+ client = HTTPClient("http://localhost:8090",
request_headers=configured_headers)
+ form_data = Mock()
+ form_data.to_dict.return_value = {"key": "value"}
+ json_data = Mock()
+ json_data.to_json.return_value = '{"key":"value"}'
+
+ with patch("gravitino.utils.http_client.build_opener") as build_opener:
+ requests = self._capture_requests(
+ build_opener, [self._success_response(),
self._success_response()]
+ )
+ client.post("/form", data=form_data)
+ client.post("/json", json=json_data)
+
+ self.assertEqual(
+ "application/x-www-form-urlencoded",
+ self._header_value(requests[0], "Content-Type"),
+ )
+ self.assertEqual(
+ "application/vnd.gravitino.v1+json",
+ self._header_value(requests[0], "Accept"),
+ )
+ self.assertEqual(
+ "application/json", self._header_value(requests[1], "Content-Type")
+ )
+ self.assertEqual(
+ "application/vnd.gravitino.v1+json",
+ self._header_value(requests[1], "Accept"),
+ )
+ for request in requests:
+ self.assertEqual("configured", self._header_value(request,
"X-Configured"))
+ self.assertEqual("configured/type", configured_headers["Content-Type"])
+ self.assertEqual(configured_headers, client.request_headers)
+
+ def test_failed_request_headers_do_not_leak(self):
+ client = HTTPClient("http://localhost:8090")
+ error_body = (
+ b'{"code":9999,"type":"RESTException",'
+ b'"message":"request failed","stack":null}'
+ )
+ error = HTTPError(
+ "http://localhost:8090/failure",
+ 500,
+ "Internal Server Error",
+ None,
+ BytesIO(error_body),
+ )
+
+ with patch("gravitino.utils.http_client.build_opener") as build_opener:
+ requests = self._capture_requests(
+ build_opener, [error, self._success_response()]
+ )
+ with self.assertRaises(RESTException):
+ client.get(
+ "/failure",
+ headers={"X-Per-Request": "failed"},
+ error_handler=REST_ERROR_HANDLER,
+ )
+ client.get("/after-failure")
+
+ self.assertEqual("failed", self._header_value(requests[0],
"X-Per-Request"))
+ self.assertIsNone(self._header_value(requests[1], "X-Per-Request"))