From 83a7067b22610cf456e125d4a647d9ed07c23d15 Mon Sep 17 00:00:00 2001 From: kwy404 Date: Sat, 26 Sep 2026 05:27:39 -0300 Subject: [PATCH 1/2] fix: avoid TypeError when a 403 error body is a JSON array _should_retry_response read data[0]["error"]["errors"]["reason"] for list-wrapped error bodies, but "errors" is a list, so any such 403 raised TypeError out of execute() instead of retrying on rate limits or raising HttpError. Index the first entry, as the dict branch does. Also make LIST_NOT_CONFIGURED_RESPONSE valid JSON so that test_no_retry_403_list_fails actually reaches the list branch. --- googleapiclient/http.py | 2 +- tests/test_http.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/googleapiclient/http.py b/googleapiclient/http.py index 5aad076854..ac13c3366d 100644 --- a/googleapiclient/http.py +++ b/googleapiclient/http.py @@ -132,7 +132,7 @@ def _should_retry_response(resp_status, content): if "reason" in reason: reason = reason["reason"] else: - reason = data[0]["error"]["errors"]["reason"] + reason = data[0]["error"]["errors"][0]["reason"] except (UnicodeDecodeError, ValueError, KeyError): LOGGER.warning("Invalid JSON content from response: %s", content) return False diff --git a/tests/test_http.py b/tests/test_http.py index 91f2c7ae52..9859a2e42b 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -895,7 +895,7 @@ def test_media_io_base_download_unknown_media_size(self): } }""" -LIST_NOT_CONFIGURED_RESPONSE = """[ +LIST_NOT_CONFIGURED_RESPONSE = """[{ "error": { "errors": [ { @@ -907,7 +907,7 @@ def test_media_io_base_download_unknown_media_size(self): "code": 403, "message": "Access Not Configured" } -]""" +}]""" class Callbacks(object): @@ -1170,6 +1170,19 @@ def test_no_retry_403_list_fails(self): request.execute() request._sleep.assert_not_called() + def test_retry_403_list_rate_limit(self): + content = json.dumps([json.loads(RATE_LIMIT_EXCEEDED_RESPONSE)]) + http = HttpMockSequence( + [({"status": "403"}, content), ({"status": "200"}, "{}")] + ) + model = JsonModel() + uri = "https://www.googleapis.com/someapi/v1/collection/?foo=bar" + request = HttpRequest(http, model.response, uri) + request._sleep = mock.MagicMock() + + self.assertEqual({}, request.execute(num_retries=1)) + request._sleep.assert_called_once() + def test_null_postproc(self): resp, content = HttpRequest.null_postproc("foo", "bar") self.assertEqual(resp, "foo") From 8f66e36d708b4e66a41fd5447a0adb9cfd61baf9 Mon Sep 17 00:00:00 2001 From: kwy404 Date: Mon, 28 Sep 2026 11:56:24 -0300 Subject: [PATCH 2/2] fix: do not raise on malformed 403 error bodies An empty list, an empty errors list or a non-dict entry in a 403 body still raised IndexError or TypeError out of _should_retry_response. Treat them like other invalid content: log a warning and do not retry, so the caller gets an HttpError. --- googleapiclient/http.py | 2 +- tests/test_http.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/googleapiclient/http.py b/googleapiclient/http.py index ac13c3366d..968ec32358 100644 --- a/googleapiclient/http.py +++ b/googleapiclient/http.py @@ -133,7 +133,7 @@ def _should_retry_response(resp_status, content): reason = reason["reason"] else: reason = data[0]["error"]["errors"][0]["reason"] - except (UnicodeDecodeError, ValueError, KeyError): + except (UnicodeDecodeError, ValueError, KeyError, IndexError, TypeError): LOGGER.warning("Invalid JSON content from response: %s", content) return False diff --git a/tests/test_http.py b/tests/test_http.py index 9859a2e42b..046997c3f1 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1183,6 +1183,17 @@ def test_retry_403_list_rate_limit(self): self.assertEqual({}, request.execute(num_retries=1)) request._sleep.assert_called_once() + def test_no_retry_403_malformed_body(self): + for content in ["[]", '[{"error": {"errors": []}}]', '["x"]', "null"]: + http = HttpMockSequence([({"status": "403"}, content)]) + uri = "https://www.googleapis.com/someapi/v1/collection/?foo=bar" + request = HttpRequest(http, JsonModel().response, uri) + request._sleep = mock.MagicMock() + + with self.assertRaises(HttpError): + request.execute(num_retries=1) + request._sleep.assert_not_called() + def test_null_postproc(self): resp, content = HttpRequest.null_postproc("foo", "bar") self.assertEqual(resp, "foo")