diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml index 53b5d8c5..f57ef988 100644 --- a/.github/release-trigger.yml +++ b/.github/release-trigger.yml @@ -13,3 +13,4 @@ # limitations under the License. enabled: true +multiScmName: alloydb-python-connector diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 6d75239d..524c1abd 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -49,16 +49,16 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3.27.3 + uses: github/codeql-action/init@babb554ede22fd5605947329c4d04d8e7a0b8155 # v3.27.7 with: languages: ${{ matrix.language }} # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually - name: Autobuild - uses: github/codeql-action/autobuild@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3.27.3 + uses: github/codeql-action/autobuild@babb554ede22fd5605947329c4d04d8e7a0b8155 # v3.27.7 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3.27.3 + uses: github/codeql-action/analyze@babb554ede22fd5605947329c4d04d8e7a0b8155 # v3.27.7 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index 2bc12c98..3185b9be 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -65,6 +65,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3.27.3 + uses: github/codeql-action/upload-sarif@babb554ede22fd5605947329c4d04d8e7a0b8155 # v3.27.7 with: sarif_file: resultsFiltered.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 16240d16..0eedcbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.6.0](https://github.com/GoogleCloudPlatform/alloydb-python-connector/compare/v1.5.0...v1.6.0) (2024-12-10) + + +### Features + +* improve aiohttp client error messages ([#400](https://github.com/GoogleCloudPlatform/alloydb-python-connector/issues/400)) ([58ef2d2](https://github.com/GoogleCloudPlatform/alloydb-python-connector/commit/58ef2d2538e3a332defefa15d19b1384db9027b1)) +* invalidate cache on bad connection info and failed IP lookup ([#389](https://github.com/GoogleCloudPlatform/alloydb-python-connector/issues/389)) ([e8fbbdf](https://github.com/GoogleCloudPlatform/alloydb-python-connector/commit/e8fbbdf80d8aa5f0df2605a779edff631e5f5a2c)) + ## [1.5.0](https://github.com/GoogleCloudPlatform/alloydb-python-connector/compare/v1.4.0...v1.5.0) (2024-11-12) diff --git a/README.md b/README.md index 861a043c..c1656027 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ [![CI][ci-badge]][ci-build] [![pypi][pypi-badge]][pypi-docs] +[![pypi][pypi-downloads]][pypi-docs] [![python][python-versions]][pypi-docs] [ci-badge]: https://github.com/GoogleCloudPlatform/alloydb-python-connector/actions/workflows/tests.yaml/badge.svg?event=push [ci-build]: https://github.com/GoogleCloudPlatform/alloydb-python-connector/actions/workflows/tests.yaml?query=event%3Apush+branch%3Amain [pypi-badge]: https://img.shields.io/pypi/v/google-cloud-alloydb-connector [pypi-docs]: https://pypi.org/project/google-cloud-alloydb-connector +[pypi-downloads]: https://img.shields.io/pypi/dm/google-cloud-alloydb-connector [python-versions]: https://img.shields.io/pypi/pyversions/google-cloud-alloydb-connector The _AlloyDB Python Connector_ is an [AlloyDB](https://cloud.google.com/alloydb) diff --git a/google/cloud/alloydb/connector/async_connector.py b/google/cloud/alloydb/connector/async_connector.py index d1961a6f..fba74887 100644 --- a/google/cloud/alloydb/connector/async_connector.py +++ b/google/cloud/alloydb/connector/async_connector.py @@ -177,8 +177,14 @@ async def connect( # if ip_type is str, convert to IPTypes enum if isinstance(ip_type, str): ip_type = IPTypes(ip_type.upper()) - conn_info = await cache.connect_info() - ip_address = conn_info.get_preferred_ip(ip_type) + try: + conn_info = await cache.connect_info() + ip_address = conn_info.get_preferred_ip(ip_type) + except Exception: + # with an error from AlloyDB API call or IP type, invalidate the + # cache and re-raise the error + await self._remove_cached(instance_uri) + raise logger.debug(f"['{instance_uri}']: Connecting to {ip_address}:5433") # callable to be used for auto IAM authn @@ -202,6 +208,15 @@ def get_authentication_token() -> str: await cache.force_refresh() raise + async def _remove_cached(self, instance_uri: str) -> None: + """Stops all background refreshes and deletes the connection + info cache from the map of caches. + """ + logger.debug(f"['{instance_uri}']: Removing connection info from cache") + # remove cache from stored caches and close it + cache = self._cache.pop(instance_uri) + await cache.close() + async def __aenter__(self) -> Any: """Enter async context manager by returning Connector object""" return self diff --git a/google/cloud/alloydb/connector/client.py b/google/cloud/alloydb/connector/client.py index 6edb9ee4..59e923a4 100644 --- a/google/cloud/alloydb/connector/client.py +++ b/google/cloud/alloydb/connector/client.py @@ -124,8 +124,20 @@ async def _get_metadata( url = f"{self._alloydb_api_endpoint}/{API_VERSION}/projects/{project}/locations/{region}/clusters/{cluster}/instances/{name}/connectionInfo" - resp = await self._client.get(url, headers=headers, raise_for_status=True) - resp_dict = await resp.json() + resp = await self._client.get(url, headers=headers) + # try to get response json for better error message + try: + resp_dict = await resp.json() + if resp.status >= 400: + # if detailed error message is in json response, use as error message + message = resp_dict.get("error", {}).get("message") + if message: + resp.reason = message + # skip, raise_for_status will catch all errors in finally block + except Exception: + pass + finally: + resp.raise_for_status() # Remove trailing period from PSC DNS name. psc_dns = resp_dict.get("pscDnsName") @@ -175,10 +187,20 @@ async def _get_client_certificate( "useMetadataExchange": self._use_metadata, } - resp = await self._client.post( - url, headers=headers, json=data, raise_for_status=True - ) - resp_dict = await resp.json() + resp = await self._client.post(url, headers=headers, json=data) + # try to get response json for better error message + try: + resp_dict = await resp.json() + if resp.status >= 400: + # if detailed error message is in json response, use as error message + message = resp_dict.get("error", {}).get("message") + if message: + resp.reason = message + # skip, raise_for_status will catch all errors in finally block + except Exception: + pass + finally: + resp.raise_for_status() return (resp_dict["caCert"], resp_dict["pemCertificateChain"]) diff --git a/google/cloud/alloydb/connector/connector.py b/google/cloud/alloydb/connector/connector.py index 2d1bfee2..c4ad2997 100644 --- a/google/cloud/alloydb/connector/connector.py +++ b/google/cloud/alloydb/connector/connector.py @@ -206,8 +206,14 @@ async def connect_async(self, instance_uri: str, driver: str, **kwargs: Any) -> # if ip_type is str, convert to IPTypes enum if isinstance(ip_type, str): ip_type = IPTypes(ip_type.upper()) - conn_info = await cache.connect_info() - ip_address = conn_info.get_preferred_ip(ip_type) + try: + conn_info = await cache.connect_info() + ip_address = conn_info.get_preferred_ip(ip_type) + except Exception: + # with an error from AlloyDB API call or IP type, invalidate the + # cache and re-raise the error + await self._remove_cached(instance_uri) + raise logger.debug(f"['{instance_uri}']: Connecting to {ip_address}:5433") # synchronous drivers are blocking and run using executor @@ -334,6 +340,15 @@ def metadata_exchange( return sock + async def _remove_cached(self, instance_uri: str) -> None: + """Stops all background refreshes and deletes the connection + info cache from the map of caches. + """ + logger.debug(f"['{instance_uri}']: Removing connection info from cache") + # remove cache from stored caches and close it + cache = self._cache.pop(instance_uri) + await cache.close() + def __enter__(self) -> "Connector": """Enter context manager by returning Connector object""" return self diff --git a/google/cloud/alloydb/connector/version.py b/google/cloud/alloydb/connector/version.py index f6d5c1cd..06fadc0e 100644 --- a/google/cloud/alloydb/connector/version.py +++ b/google/cloud/alloydb/connector/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "1.5.0" +__version__ = "1.6.0" diff --git a/requirements-test.txt b/requirements-test.txt index 11bf97eb..d0c14928 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,8 +1,9 @@ asyncpg==0.30.0 mock==5.1.0 pg8000==1.31.2 -pytest==8.3.3 +pytest==8.3.4 pytest-asyncio==0.24.0 pytest-cov==6.0.0 pytest-aiohttp==1.0.5 SQLAlchemy[asyncio]==2.0.36 +aioresponses==0.7.7 diff --git a/requirements.txt b/requirements.txt index 148b1b4f..2d898668 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ aiofiles==24.1.0 -aiohttp==3.10.10 -cryptography==43.0.3 +aiohttp==3.11.10 +cryptography==44.0.0 google-auth==2.36.0 requests==2.32.3 protobuf==4.25.5 diff --git a/tests/unit/test_async_connector.py b/tests/unit/test_async_connector.py index e2b22b10..0f150875 100644 --- a/tests/unit/test_async_connector.py +++ b/tests/unit/test_async_connector.py @@ -15,6 +15,7 @@ import asyncio from typing import Union +from aiohttp import ClientResponseError from mock import patch from mocks import FakeAlloyDBClient from mocks import FakeConnectionInfo @@ -23,6 +24,8 @@ from google.cloud.alloydb.connector import AsyncConnector from google.cloud.alloydb.connector import IPTypes +from google.cloud.alloydb.connector.exceptions import IPTypeNotFoundError +from google.cloud.alloydb.connector.instance import RefreshAheadCache ALLOYDB_API_ENDPOINT = "https://alloydb.googleapis.com" @@ -294,3 +297,39 @@ async def test_async_connect_bad_ip_type( exc_info.value.args[0] == f"Incorrect value for ip_type, got '{bad_ip_type}'. Want one of: 'PUBLIC', 'PRIVATE', 'PSC'." ) + + +async def test_Connector_remove_cached_bad_instance( + credentials: FakeCredentials, +) -> None: + """When a Connector attempts to retrieve connection info for a + non-existent instance, it should delete the instance from + the cache and ensure no background refresh happens (which would be + wasted cycles). + """ + instance_uri = "projects/test-project/locations/test-region/clusters/test-cluster/instances/bad-test-instance" + async with AsyncConnector(credentials=credentials) as connector: + with pytest.raises(ClientResponseError): + await connector.connect(instance_uri, "asyncpg") + assert instance_uri not in connector._cache + + +async def test_Connector_remove_cached_no_ip_type(credentials: FakeCredentials) -> None: + """When a Connector attempts to connect and preferred IP type is not present, + it should delete the instance from the cache and ensure no background refresh + happens (which would be wasted cycles). + """ + instance_uri = "projects/test-project/locations/test-region/clusters/test-cluster/instances/test-instance" + # set instance to only have Public IP + fake_client = FakeAlloyDBClient() + fake_client.instance.ip_addrs = {"PUBLIC": "127.0.0.1"} + async with AsyncConnector(credentials=credentials) as connector: + connector._client = fake_client + # populate cache + cache = RefreshAheadCache(instance_uri, fake_client, connector._keys) + connector._cache[instance_uri] = cache + # test instance does not have Private IP, thus should invalidate cache + with pytest.raises(IPTypeNotFoundError): + await connector.connect(instance_uri, "asyncpg", ip_type="private") + # check that cache has been removed from dict + assert instance_uri not in connector._cache diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 3da68079..e4b2fdbb 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -15,7 +15,9 @@ import json from typing import Any, Optional +from aiohttp import ClientResponseError from aiohttp import web +from aioresponses import aioresponses from mocks import FakeCredentials import pytest @@ -138,6 +140,75 @@ async def test__get_metadata_with_psc( } +async def test__get_metadata_error( + credentials: FakeCredentials, +) -> None: + """ + Test that AlloyDB API error messages are raised for _get_metadata. + """ + # mock AlloyDB API calls with exceptions + client = AlloyDBClient( + alloydb_api_endpoint="https://alloydb.googleapis.com", + quota_project=None, + credentials=credentials, + ) + get_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster/instances/my-instance/connectionInfo" + resp_body = { + "error": { + "code": 403, + "message": "AlloyDB API has not been used in project 123456789 before or it is disabled", + } + } + with aioresponses() as mocked: + mocked.get( + get_url, + status=403, + payload=resp_body, + repeat=True, + ) + with pytest.raises(ClientResponseError) as exc_info: + await client._get_metadata( + "my-project", "my-region", "my-cluster", "my-instance" + ) + assert exc_info.value.status == 403 + assert ( + exc_info.value.message + == "AlloyDB API has not been used in project 123456789 before or it is disabled" + ) + await client.close() + + +async def test__get_metadata_error_parsing_json( + credentials: FakeCredentials, +) -> None: + """ + Test that aiohttp default error messages are raised when _get_metadata gets + a bad JSON response. + """ + # mock AlloyDB API calls with exceptions + client = AlloyDBClient( + alloydb_api_endpoint="https://alloydb.googleapis.com", + quota_project=None, + credentials=credentials, + ) + get_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster/instances/my-instance/connectionInfo" + resp_body = ["error"] # invalid json + with aioresponses() as mocked: + mocked.get( + get_url, + status=403, + payload=resp_body, + repeat=True, + ) + with pytest.raises(ClientResponseError) as exc_info: + await client._get_metadata( + "my-project", "my-region", "my-cluster", "my-instance" + ) + assert exc_info.value.status == 403 + assert exc_info.value.message == "Forbidden" + await client.close() + + @pytest.mark.asyncio async def test__get_client_certificate( client: Any, credentials: FakeCredentials @@ -157,6 +228,72 @@ async def test__get_client_certificate( assert cert_chain[2] == "This is the root cert" +async def test__get_client_certificate_error( + credentials: FakeCredentials, +) -> None: + """ + Test that AlloyDB API error messages are raised for _get_client_certificate. + """ + # mock AlloyDB API calls with exceptions + client = AlloyDBClient( + alloydb_api_endpoint="https://alloydb.googleapis.com", + quota_project=None, + credentials=credentials, + ) + post_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster:generateClientCertificate" + resp_body = { + "error": { + "code": 404, + "message": "The AlloyDB instance does not exist.", + } + } + with aioresponses() as mocked: + mocked.post( + post_url, + status=404, + payload=resp_body, + repeat=True, + ) + with pytest.raises(ClientResponseError) as exc_info: + await client._get_client_certificate( + "my-project", "my-region", "my-cluster", "" + ) + assert exc_info.value.status == 404 + assert exc_info.value.message == "The AlloyDB instance does not exist." + await client.close() + + +async def test__get_client_certificate_error_parsing_json( + credentials: FakeCredentials, +) -> None: + """ + Test that aiohttp default error messages are raised when + _get_client_certificate gets a bad JSON response. + """ + # mock AlloyDB API calls with exceptions + client = AlloyDBClient( + alloydb_api_endpoint="https://alloydb.googleapis.com", + quota_project=None, + credentials=credentials, + ) + post_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster:generateClientCertificate" + resp_body = ["error"] # invalid json + with aioresponses() as mocked: + mocked.post( + post_url, + status=404, + payload=resp_body, + repeat=True, + ) + with pytest.raises(ClientResponseError) as exc_info: + await client._get_client_certificate( + "my-project", "my-region", "my-cluster", "" + ) + assert exc_info.value.status == 404 + assert exc_info.value.message == "Not Found" + await client.close() + + @pytest.mark.asyncio async def test_AlloyDBClient_init_(credentials: FakeCredentials) -> None: """ diff --git a/tests/unit/test_connector.py b/tests/unit/test_connector.py index 95b62239..a02ad30e 100644 --- a/tests/unit/test_connector.py +++ b/tests/unit/test_connector.py @@ -16,6 +16,7 @@ from threading import Thread from typing import Union +from aiohttp import ClientResponseError from mock import patch from mocks import FakeAlloyDBClient from mocks import FakeCredentials @@ -23,6 +24,9 @@ from google.cloud.alloydb.connector import Connector from google.cloud.alloydb.connector import IPTypes +from google.cloud.alloydb.connector.exceptions import IPTypeNotFoundError +from google.cloud.alloydb.connector.instance import RefreshAheadCache +from google.cloud.alloydb.connector.utils import generate_keys def test_Connector_init(credentials: FakeCredentials) -> None: @@ -203,3 +207,44 @@ def test_Connector_close_called_multiple_times(credentials: FakeCredentials) -> assert connector._thread.is_alive() is False # call connector.close a second time connector.close() + + +def test_Connector_remove_cached_bad_instance( + credentials: FakeCredentials, +) -> None: + """When a Connector attempts to retrieve connection info for a + non-existent instance, it should delete the instance from + the cache and ensure no background refresh happens (which would be + wasted cycles). + """ + instance_uri = "projects/test-project/locations/test-region/clusters/test-cluster/instances/bad-test-instance" + with Connector(credentials) as connector: + with pytest.raises(ClientResponseError): + connector.connect(instance_uri, "pg8000") + assert instance_uri not in connector._cache + + +async def test_Connector_remove_cached_no_ip_type(credentials: FakeCredentials) -> None: + """When a Connector attempts to connect and preferred IP type is not present, + it should delete the instance from the cache and ensure no background refresh + happens (which would be wasted cycles). + """ + instance_uri = "projects/test-project/locations/test-region/clusters/test-cluster/instances/test-instance" + # set instance to only have Public IP + fake_client = FakeAlloyDBClient() + fake_client.instance.ip_addrs = {"PUBLIC": "127.0.0.1"} + with Connector(credentials=credentials) as connector: + connector._client = fake_client + connector._keys = asyncio.wrap_future( + asyncio.run_coroutine_threadsafe( + generate_keys(), asyncio.get_running_loop() + ), + loop=asyncio.get_running_loop(), + ) + cache = RefreshAheadCache(instance_uri, fake_client, connector._keys) + connector._cache[instance_uri] = cache + # test instance does not have Private IP, thus should invalidate cache + with pytest.raises(IPTypeNotFoundError): + await connector.connect_async(instance_uri, "pg8000", ip_type="private") + # check that cache has been removed from dict + assert instance_uri not in connector._cache