From 928fa2ca357456aa6fb3711d4ef53464b0555592 Mon Sep 17 00:00:00 2001 From: ayam04 Date: Sat, 22 Aug 2026 19:09:31 +0530 Subject: [PATCH 1/2] fix(bigquery): scope SSLError non-retryable to streaming inserts #17489 made SSLError globally non-retryable, but _should_retry is the base predicate for every retry surface: a single transient TLS reset (SSLEOFError) now fails jobs.get / result() polling outright, where 3.40.1 retried it as a ConnectionError subclass. Remove SSLError from the global non-retryable set and keep the #17489 carve-out only on the insertAll path via a scoped predicate (INSERT_ROWS_DEFAULT_RETRY): malformed streaming payloads still fail fast, while job polling keeps retrying transient transport resets. Fixes #18178 --- .../google/cloud/bigquery/client.py | 3 +- .../google/cloud/bigquery/retry.py | 29 ++++++++++++++++++- .../tests/unit/test_retry.py | 21 +++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/google-cloud-bigquery/google/cloud/bigquery/client.py b/packages/google-cloud-bigquery/google/cloud/bigquery/client.py index 07fec2fc0fa9..315a6c32b8ba 100644 --- a/packages/google-cloud-bigquery/google/cloud/bigquery/client.py +++ b/packages/google-cloud-bigquery/google/cloud/bigquery/client.py @@ -113,6 +113,7 @@ DEFAULT_GET_JOB_TIMEOUT, DEFAULT_JOB_RETRY, DEFAULT_RETRY, + INSERT_ROWS_DEFAULT_RETRY, DEFAULT_TIMEOUT, POLLING_DEFAULT_VALUE, ) @@ -3956,7 +3957,7 @@ def insert_rows_json( skip_invalid_rows: Optional[bool] = None, ignore_unknown_values: Optional[bool] = None, template_suffix: Optional[str] = None, - retry: retries.Retry = DEFAULT_RETRY, + retry: retries.Retry = INSERT_ROWS_DEFAULT_RETRY, timeout: TimeoutType = DEFAULT_TIMEOUT, ) -> Sequence[dict]: """Insert rows into a table without applying local type conversions. diff --git a/packages/google-cloud-bigquery/google/cloud/bigquery/retry.py b/packages/google-cloud-bigquery/google/cloud/bigquery/retry.py index 4e78e7d28dcb..5a905fa48888 100644 --- a/packages/google-cloud-bigquery/google/cloud/bigquery/retry.py +++ b/packages/google-cloud-bigquery/google/cloud/bigquery/retry.py @@ -43,7 +43,14 @@ # Exceptions that are subclasses of types in _UNSTRUCTURED_RETRYABLE_TYPES # but should not be retried because they typically indicate persistent # configuration or security issues. -_UNSTRUCTURED_NON_RETRYABLE_TYPES = (requests.exceptions.SSLError,) +# +# NOTE: requests.exceptions.SSLError is deliberately NOT listed here. It is a +# subclass of requests.exceptions.ConnectionError, and transient TLS resets +# (e.g. SSLEOFError during a pooled-connection handshake) are transport errors +# that the client retried before #17489. That PR's carve-out belongs to the +# streaming-insert path only (see INSERT_ROWS_DEFAULT_RETRY); making it +# global broke jobs.get / result() polling on transient resets. +_UNSTRUCTURED_NON_RETRYABLE_TYPES = () # Ambiguous errors (e.g. internalError, backendError, rateLimitExceeded) retry # until the full `_DEFAULT_RETRY_DEADLINE`. This is because the @@ -88,6 +95,26 @@ def _should_retry(exc): DEFAULT_RETRY = retry.Retry(predicate=_should_retry, deadline=_DEFAULT_RETRY_DEADLINE) + + +def _should_retry_insert_rows(exc): + """Predicate for the streaming-insert (insertAll) path. + + A connection-level SSLError there is usually the transport rejecting a + malformed payload (e.g. an invalid table schema), which does not resolve + on retry. Scope that carve-out here rather than globally: jobs.get and + result() polling must keep retrying transient TLS resets. + """ + if isinstance(exc, requests.exceptions.SSLError): + return False + return _should_retry(exc) + + +# Streaming inserts keep the SSLError carve-out from #17489, scoped to the +# insertAll path only. +INSERT_ROWS_DEFAULT_RETRY = retry.Retry( + predicate=_should_retry_insert_rows, deadline=_DEFAULT_RETRY_DEADLINE +) """The default retry object. Any method with a ``retry`` parameter will be retried automatically, diff --git a/packages/google-cloud-bigquery/tests/unit/test_retry.py b/packages/google-cloud-bigquery/tests/unit/test_retry.py index a249d1909909..9c9fe7d94ad5 100644 --- a/packages/google-cloud-bigquery/tests/unit/test_retry.py +++ b/packages/google-cloud-bigquery/tests/unit/test_retry.py @@ -53,7 +53,7 @@ def test_w_unstructured_requests_connectionerror(self): def test_w_unstructured_requests_sslerror(self): exc = requests.exceptions.SSLError() - self.assertFalse(self._call_fut(exc)) + self.assertTrue(self._call_fut(exc)) def test_w_unstructured_requests_chunked_encoding_error(self): exc = requests.exceptions.ChunkedEncodingError() @@ -160,3 +160,22 @@ def test_DEFAULT_JOB_RETRY_job_rate_limit_exceeded_retry_predicate(): assert DEFAULT_JOB_RETRY._predicate( ClientError("fail", errors=[dict(reason="backendError")]) ) + + +class Test_should_retry_insert_rows(unittest.TestCase): + def _call_fut(self, exc): + from google.cloud.bigquery.retry import _should_retry_insert_rows + + return _should_retry_insert_rows(exc) + + def test_w_sslerror(self): + exc = requests.exceptions.SSLError() + self.assertFalse(self._call_fut(exc)) + + def test_w_unstructured_connectionerror(self): + exc = requests.exceptions.ConnectionError() + self.assertTrue(self._call_fut(exc)) + + def test_w_rate_limited(self): + exc = mock.Mock(errors=[{"reason": "rateLimitExceeded"}], spec=["errors"]) + self.assertTrue(self._call_fut(exc)) From c90ab067e25c5dc7ad37ac29de84a9fea35b9862 Mon Sep 17 00:00:00 2001 From: ayam04 Date: Sat, 22 Aug 2026 20:48:14 +0530 Subject: [PATCH 2/2] test(bigquery): lock insert_rows default to the scoped retry predicate --- .../tests/unit/test_retry.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/google-cloud-bigquery/tests/unit/test_retry.py b/packages/google-cloud-bigquery/tests/unit/test_retry.py index 9c9fe7d94ad5..f287976beb43 100644 --- a/packages/google-cloud-bigquery/tests/unit/test_retry.py +++ b/packages/google-cloud-bigquery/tests/unit/test_retry.py @@ -179,3 +179,24 @@ def test_w_unstructured_connectionerror(self): def test_w_rate_limited(self): exc = mock.Mock(errors=[{"reason": "rateLimitExceeded"}], spec=["errors"]) self.assertTrue(self._call_fut(exc)) + + +class Test_insert_rows_default_retry(unittest.TestCase): + def test_insert_rows_json_defaults_to_scoped_retry(self): + from types import MethodType + from google.cloud.bigquery.retry import INSERT_ROWS_DEFAULT_RETRY, _should_retry_insert_rows + + # The default retry object on insert_rows_json is the scoped one, + # so transient SSLErrors on polling paths stay retryable while the + # streaming-insert carve-out is preserved. + self.assertIs( + INSERT_ROWS_DEFAULT_RETRY._predicate, + _should_retry_insert_rows, + ) + + def test_scoped_predicate_keeps_connection_errors_retryable(self): + from google.cloud.bigquery.retry import _should_retry_insert_rows + + self.assertTrue( + _should_retry_insert_rows(requests.exceptions.ConnectionError()) + )