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

Reply via email to