This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 711cc64750 [python] Fix locale-sensitive Date header and nonce replay
on retry (#9173)
711cc64750 is described below
commit 711cc647509ea0e2d8cac85922d3ed19712afe10
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Wed Aug 12 11:32:20 2026 +0800
[python] Fix locale-sensitive Date header and nonce replay on retry (#9173)
---
paimon-python/pypaimon/api/auth/dlf_signer.py | 15 ++++++++--
paimon-python/pypaimon/api/client.py | 19 ++++++++----
.../pypaimon/tests/rest/dlf_signer_test.py | 34 ++++++++++++++++++++++
.../tests/rest/test_exponential_retry_strategy.py | 10 ++++++-
4 files changed, 69 insertions(+), 9 deletions(-)
diff --git a/paimon-python/pypaimon/api/auth/dlf_signer.py
b/paimon-python/pypaimon/api/auth/dlf_signer.py
index 8f6f3871bd..94ec6eb866 100644
--- a/paimon-python/pypaimon/api/auth/dlf_signer.py
+++ b/paimon-python/pypaimon/api/auth/dlf_signer.py
@@ -307,7 +307,6 @@ class DLFOpenApiSigner(DLFRequestSigner):
X_ACS_SECURITY_TOKEN = "x-acs-security-token"
# Values
- DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
ACCEPT_VALUE = "application/json"
CONTENT_TYPE_VALUE = "application/json"
SIGNATURE_METHOD_VALUE = "HMAC-SHA1"
@@ -315,6 +314,13 @@ class DLFOpenApiSigner(DLFRequestSigner):
API_VERSION = "2026-01-18"
HMAC_SHA1 = "sha1"
+ # English weekday/month abbreviations for RFC 1123 dates. strftime's
+ # %a/%b follow LC_TIME and get localized under non-English locales,
+ # which would break the Aliyun OpenAPI signature.
+ WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
+ MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
+
def sign_headers(
self,
body: Optional[str],
@@ -333,7 +339,12 @@ class DLFOpenApiSigner(DLFRequestSigner):
gmt_time = now.replace(tzinfo=timezone.utc)
else:
gmt_time = now.astimezone(timezone.utc)
- headers[self.DATE_HEADER] = gmt_time.strftime(self.DATE_FORMAT)
+ # RFC 1123 date, e.g. "Wed, 16 Apr 2025 03:44:46 GMT"
+ headers[self.DATE_HEADER] = (
+ f"{self.WEEKDAYS[gmt_time.weekday()]}, {gmt_time.day:02d} "
+ f"{self.MONTHS[gmt_time.month - 1]} {gmt_time.year:04d} "
+ f"{gmt_time.hour:02d}:{gmt_time.minute:02d}:{gmt_time.second:02d}
GMT"
+ )
headers[self.ACCEPT_HEADER] = self.ACCEPT_VALUE
diff --git a/paimon-python/pypaimon/api/client.py
b/paimon-python/pypaimon/api/client.py
index c5a1396b88..55730887ba 100644
--- a/paimon-python/pypaimon/api/client.py
+++ b/paimon-python/pypaimon/api/client.py
@@ -154,16 +154,23 @@ class ExponentialRetry:
@staticmethod
def __create_retry_strategy(max_retries: int) -> Retry:
- # Single retry budget shared across read and status (429 / 5xx)
- # errors. Connect failures are intentionally non-retriable: a
- # connect error usually means the host is wrong or the listener
- # is down, and burning the budget on it just delays the failure.
+ # Aligned with the Java client's retry triggers:
+ # - only 429 / 503 responses are retried; 502 / 504 are not,
+ # because by then the gateway has consumed the request's
+ # signature nonce, and retrying with the same signed headers is
+ # rejected with "Specified signature nonce was used already"
+ # - read errors (including read timeouts) are not retried for the
+ # same reason: the request has likely reached the server
+ # - connect failures are intentionally non-retriable: a connect
+ # error usually means the host is wrong or the listener is down,
+ # and burning the budget on it just delays the failure.
retry_kwargs = {
'total': max_retries,
- 'read': max_retries,
+ 'read': 0,
'connect': 0,
+ 'status': max_retries,
'backoff_factor': 1,
- 'status_forcelist': [429, 502, 503, 504],
+ 'status_forcelist': [429, 503],
'raise_on_status': False,
'raise_on_redirect': False,
}
diff --git a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
index bbda410aec..e29842ba59 100644
--- a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
+++ b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
@@ -16,6 +16,7 @@
# under the License.
import unittest
+import locale
import re
import threading
from datetime import datetime, timezone
@@ -87,6 +88,39 @@ class DLFSignerTest(unittest.TestCase):
# Test identifier
self.assertEqual("openapi", signer.identifier())
+ def test_openapi_date_format_with_chinese_locale(self):
+ """Date header must stay English RFC 1123 under zh_CN locale."""
+ original = locale.setlocale(locale.LC_TIME, None)
+ try:
+ self._set_lc_time_or_skip(["zh_CN.UTF-8", "zh_CN.utf8", "zh_CN"])
+ self._assert_openapi_date_in_english()
+ finally:
+ locale.setlocale(locale.LC_TIME, original)
+
+ def test_openapi_date_format_with_japanese_locale(self):
+ """Date header must stay English RFC 1123 under ja_JP locale."""
+ original = locale.setlocale(locale.LC_TIME, None)
+ try:
+ self._set_lc_time_or_skip(["ja_JP.UTF-8", "ja_JP.utf8", "ja_JP"])
+ self._assert_openapi_date_in_english()
+ finally:
+ locale.setlocale(locale.LC_TIME, original)
+
+ def _set_lc_time_or_skip(self, candidates):
+ for name in candidates:
+ try:
+ locale.setlocale(locale.LC_TIME, name)
+ return
+ except locale.Error:
+ continue
+ self.skipTest(f"None of the locales {candidates} is available on this
system")
+
+ def _assert_openapi_date_in_english(self):
+ signer = DLFOpenApiSigner()
+ now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+ headers = signer.sign_headers(None, now, None,
"dlfnext.cn-hangzhou.aliyuncs.com")
+ self.assertEqual("Wed, 16 Apr 2025 03:44:46 GMT", headers.get("Date"))
+
def test_get_authorization(self):
"""Test exact signature output matches."""
region = "cn-hangzhou"
diff --git
a/paimon-python/pypaimon/tests/rest/test_exponential_retry_strategy.py
b/paimon-python/pypaimon/tests/rest/test_exponential_retry_strategy.py
index 512ab94e6d..b4f3f8085a 100644
--- a/paimon-python/pypaimon/tests/rest/test_exponential_retry_strategy.py
+++ b/paimon-python/pypaimon/tests/rest/test_exponential_retry_strategy.py
@@ -30,14 +30,22 @@ class TestExponentialRetryStrategy(unittest.TestCase):
retry = ExponentialRetry._ExponentialRetry__create_retry_strategy(5)
self.assertEqual(retry.total, 5)
- self.assertEqual(retry.read, 5)
+ # Read errors / timeouts are not retried: the request has likely
+ # reached the server and its signature nonce is already consumed,
+ # so a retry with the same signed headers would be rejected with
+ # "Specified signature nonce was used already".
+ self.assertEqual(retry.read, 0)
# Connect failures are intentionally non-retriable — see the
# comment on ``ExponentialRetry.__create_retry_strategy``.
self.assertEqual(retry.connect, 0)
+ self.assertEqual(retry.status, 5)
+ # Aligned with the Java client: only 429 / 503 are retried.
self.assertIn(429, retry.status_forcelist) # Too Many Requests
self.assertIn(503, retry.status_forcelist) # Service Unavailable
self.assertNotIn(404, retry.status_forcelist)
+ self.assertNotIn(502, retry.status_forcelist)
+ self.assertNotIn(504, retry.status_forcelist)
def test_retry_on_connect_error(self):
# ``connect=0`` means connect errors are not retried — the