Repository: GoogleCloudPlatform/cloud-sql-python-connector
Pull request #1127 (2024-07-09 17:59:47)

chore(deps): bump zipp from 3.10.0 to 3.19.1 in /.kokoro
Bumps [zipp](https://github.com/jaraco/zipp) from 3.10.0 to 3.19.1.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/jaraco/zipp/blob/main/NEWS.rst">zipp's changelog</a>.</em></p>
<blockquote>
<h1>v3.19.1</h1>
<h2>Bugfixes</h2>
<ul>
<li>Improved handling of malformed zip files. (<a href="https://redirect.github.com/jaraco/zipp/issues/119">#119</a>)</li>
</ul>
<h1>v3.19.0</h1>
<h2>Features</h2>
<ul>
<li>Implement is_symlink. (<a href="https://redirect.github.com/jaraco/zipp/issues/117">#117</a>)</li>
</ul>
<h1>v3.18.2</h1>
<p>No significant changes.</p>
<h1>v3.18.1</h1>
<p>No significant changes.</p>
<h1>v3.18.0</h1>
<h2>Features</h2>
<ul>
<li>Bypass ZipFile.namelist in glob for better performance. (<a href="https://redirect.github.com/jaraco/zipp/issues/106">#106</a>)</li>
<li>Refactored glob functionality to support a more generalized solution with support for platform-specific path separators. (<a href="https://redirect.github.com/jaraco/zipp/issues/108">#108</a>)</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>Add special accounting for pypy when computing the stack level for text encoding warnings. (<a href="https://redirect.github.com/jaraco/zipp/issues/114">#114</a>)</li>
</ul>
<h1>v3.17.0</h1>
<p>Features</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/jaraco/zipp/commit/6d1cb72aa55e0536f35d2af128994b5a61ca4c1a"><code>6d1cb72</code></a> Finalize</li>
<li><a href="https://github.com/jaraco/zipp/commit/fd604bd34f0343472521a36da1fbd22e793e14fd"><code>fd604bd</code></a> Merge pull request <a href="https://redirect.github.com/jaraco/zipp/issues/120">#120</a> from jaraco/bugfix/119-malformed-paths</li>
<li><a href="https://github.com/jaraco/zipp/commit/c18417ed2953e181728a7dac07bff88a2190abf7"><code>c18417e</code></a> Add news fragment.</li>
<li><a href="https://github.com/jaraco/zipp/commit/58115d2be968644ce71ce6bcc9b79826c82a1806"><code>58115d2</code></a> Employ SanitizedNames in CompleteDirs. Fixes broken test.</li>
<li><a href="https://github.com/jaraco/zipp/commit/564fcc10cdbfdaecdb33688e149827465931c9e0"><code>564fcc1</code></a> Add SanitizedNames mixin.</li>
<li><a href="https://github.com/jaraco/zipp/commit/79a309fe54dc6b7934fb72e9f31bcb58f2e9f547"><code>79a309f</code></a> Add some assertions about malformed paths.</li>
<li><a href="https://github.com/jaraco/zipp/commit/2d015c22348fab46ca765339f55f84fe9d6e8115"><code>2d015c2</code></a> Merge <a href="https://github.com/jaraco/skeleton">https://github.com/jaraco/skeleton</a></li>
<li><a href="https://github.com/jaraco/zipp/commit/a595a0fad054cd20b69d3e954c99174e3a548938"><code>a595a0f</code></a> Rename extras to align with core metadata spec.</li>
<li><a href="https://github.com/jaraco/zipp/commit/608f90a6e74919501577a1312dc5c7d8e1d391d7"><code>608f90a</code></a> Finalize</li>
<li><a href="https://github.com/jaraco/zipp/commit/3a22d724acf874111b43f87f7110225122ec3de5"><code>3a22d72</code></a> Merge pull request <a href="https://redirect.github.com/jaraco/zipp/issues/118">#118</a> from jaraco/feature/is-symlink</li>
<li>Additional commits viewable in <a href="https://github.com/jaraco/zipp/compare/v3.10.0...v3.19.1">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=zipp&package-manager=pip&previous-version=3.10.0&new-version=3.19.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/network/alerts).

</details>


Patch:
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index b74e647..31bacde 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -47,7 +47,7 @@ jobs:
             }
 
       - name: Setup Python
-        uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
+        uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
         with:
           python-version: "3.10"
 
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index ebc5e75..faef351 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -48,7 +48,7 @@ jobs:
             }
 
       - name: Setup Python
-        uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
+        uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
         with:
           python-version: "3.12"
       
diff --git a/.kokoro/requirements.txt b/.kokoro/requirements.txt
index 6c93090..1810635 100644
--- a/.kokoro/requirements.txt
+++ b/.kokoro/requirements.txt
@@ -494,9 +494,9 @@ wheel==0.38.4 \
     --hash=sha256:965f5259b566725405b05e7cf774052044b1ed30119b5d586b2703aafe8719ac \
     --hash=sha256:b60533f3f5d530e971d6737ca6d58681ee434818fab630c83a734bb10c083ce8
     # via -r requirements.in
-zipp==3.10.0 \
-    --hash=sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1 \
-    --hash=sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8
+zipp==3.19.1 \
+    --hash=sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091 \
+    --hash=sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f
     # via importlib-metadata
 
 # The following packages are considered to be unsafe in a requirements file:
diff --git a/google/cloud/sql/connector/client.py b/google/cloud/sql/connector/client.py
index f36c572..e5af54b 100644
--- a/google/cloud/sql/connector/client.py
+++ b/google/cloud/sql/connector/client.py
@@ -28,6 +28,7 @@ from google.auth.transport import requests
 from google.cloud.sql.connector.connection_info import ConnectionInfo
 from google.cloud.sql.connector.exceptions import AutoIAMAuthNotSupported
 from google.cloud.sql.connector.refresh_utils import _downscope_credentials
+from google.cloud.sql.connector.refresh_utils import retry_50x
 from google.cloud.sql.connector.version import __version__ as version
 
 if TYPE_CHECKING:
@@ -124,7 +125,10 @@ class CloudSQLClient:
 
         url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/projects/{project}/instances/{instance}/connectSettings"
 
-        resp = await self._client.get(url, headers=headers, raise_for_status=True)
+        resp = await self._client.get(url, headers=headers)
+        if resp.status >= 500:
+            resp = await retry_50x(self._client.get, url, headers=headers)
+        resp.raise_for_status()
         ret_dict = await resp.json()
 
         if ret_dict["region"] != region:
@@ -188,10 +192,10 @@ class CloudSQLClient:
             login_creds = _downscope_credentials(self._credentials)
             data["access_token"] = login_creds.token
 
-        resp = await self._client.post(
-            url, headers=headers, json=data, raise_for_status=True
-        )
-
+        resp = await self._client.post(url, headers=headers, json=data)
+        if resp.status >= 500:
+            resp = await retry_50x(self._client.post, url, headers=headers, json=data)
+        resp.raise_for_status()
         ret_dict = await resp.json()
 
         ephemeral_cert: str = ret_dict["ephemeralCert"]["cert"]
diff --git a/google/cloud/sql/connector/refresh_utils.py b/google/cloud/sql/connector/refresh_utils.py
index 1d678c5..bc37110 100644
--- a/google/cloud/sql/connector/refresh_utils.py
+++ b/google/cloud/sql/connector/refresh_utils.py
@@ -20,8 +20,10 @@ import asyncio
 import copy
 import datetime
 import logging
-from typing import List
+import random
+from typing import Any, Callable, List
 
+import aiohttp
 from google.auth.credentials import Credentials
 from google.auth.credentials import Scoped
 import google.auth.transport.requests
@@ -105,3 +107,48 @@ def _downscope_credentials(
     request = google.auth.transport.requests.Request()
     scoped_creds.refresh(request)
     return scoped_creds
+
+
+def _exponential_backoff(attempt: int) -> float:
+    """Calculates a duration to backoff in milliseconds based on the attempt i.
+
+    The formula is:
+
+    base * multi^(attempt + 1 + random)
+
+    With base = 200ms and multi = 1.618, and random = [0.0, 1.0),
+    the backoff values would fall between the following low and high ends:
+
+    Attempt  Low (ms)  High (ms)
+
+    0         324	     524
+    1         524	     847
+    2         847	    1371
+    3        1371	    2218
+    4        2218	    3588
+
+    The theoretical worst case scenario would have a client wait 8.5s in total
+    for an API request to complete (with the first four attempts failing, and
+    the fifth succeeding).
+    """
+    base = 200
+    multi = 1.618
+    exp = attempt + 1 + random.random()
+    return base * pow(multi, exp)
+
+
+async def retry_50x(
+    request_coro: Callable, *args: Any, **kwargs: Any
+) -> aiohttp.ClientResponse:
+    """Retry any 50x HTTP response up to X number of times."""
+    max_retries = 5
+    for i in range(max_retries):
+        resp = await request_coro(*args, **kwargs)
+        # backoff for any 50X errors
+        if resp.status >= 500 and i < max_retries:
+            # calculate backoff time
+            backoff = _exponential_backoff(i)
+            await asyncio.sleep(backoff / 1000)
+        else:
+            break
+    return resp


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index dcc2100..2f41401 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -67,7 +67,7 @@ jobs:
           repository: ${{ github.event.pull_request.head.repo.full_name }}
 
       - name: Setup Python ${{ matrix.python-version }}
-        uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
+        uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -184,7 +184,7 @@ jobs:
           repository: ${{ github.event.pull_request.head.repo.full_name }}
 
       - name: Setup Python ${{ matrix.python-version }}
-        uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
+        uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
         with:
           python-version: ${{ matrix.python-version }}
 
diff --git a/tests/unit/test_refresh_utils.py b/tests/unit/test_refresh_utils.py
index 984287a..476c468 100644
--- a/tests/unit/test_refresh_utils.py
+++ b/tests/unit/test_refresh_utils.py
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 """
 
+from __future__ import annotations
+
 import asyncio
 import datetime
 
@@ -27,8 +29,10 @@ from mock import patch
 import pytest  # noqa F401 Needed to run the tests
 
 from google.cloud.sql.connector.refresh_utils import _downscope_credentials
+from google.cloud.sql.connector.refresh_utils import _exponential_backoff
 from google.cloud.sql.connector.refresh_utils import _is_valid
 from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
+from google.cloud.sql.connector.refresh_utils import retry_50x
 
 
 @pytest.fixture
@@ -148,3 +152,50 @@ def test_seconds_until_refresh_under_4_mins() -> None:
         )
         == 0
     )
+
+
+@pytest.mark.parametrize(
+    "attempt, low, high",
+    [
+        (0, 324, 524),
+        (1, 524, 847),
+        (2, 847, 1371),
+        (3, 1371, 2218),
+        (4, 2218, 3588),
+    ],
+)
+def test_exponential_backoff(attempt: int, low: int, high: int) -> None:
+    """
+    Test _exponential_backoff produces times (in ms) in the proper range.
+    """
+    backoff = _exponential_backoff(attempt)
+    assert backoff >= low
+    assert backoff <= high
+
+
+class RetryClass:
+    def __init__(self) -> None:
+        self.attempts = 0
+
+    async def fake_request(self, status: int) -> RetryClass:
+        self.status = status
+        self.attempts += 1
+        return self
+
+
+async def test_retry_50x_with_503() -> None:
+    fake_client = RetryClass()
+    resp = await retry_50x(fake_client.fake_request, 503)
+    assert resp.attempts == 5
+
+
+async def test_retry_50x_with_200() -> None:
+    fake_client = RetryClass()
+    resp = await retry_50x(fake_client.fake_request, 200)
+    assert resp.attempts == 1
+
+
+async def test_retry_50x_with_400() -> None:
+    fake_client = RetryClass()
+    resp = await retry_50x(fake_client.fake_request, 400)
+    assert resp.attempts == 1