Skip to content

Commit 25fa76d

Browse files
feat: improve aiohttp client error messages
This change is similar to GoogleCloudPlatform/cloud-sql-python-connector#1201, except for AlloyDB. In short, we want to use the AlloyDB API's response body for error messages, because they are more detailed.
1 parent 5255fbb commit 25fa76d

File tree

3 files changed

+165
-7
lines changed

3 files changed

+165
-7
lines changed

google/cloud/alloydb/connector/client.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,20 @@ async def _get_metadata(
124124

125125
url = f"{self._alloydb_api_endpoint}/{API_VERSION}/projects/{project}/locations/{region}/clusters/{cluster}/instances/{name}/connectionInfo"
126126

127-
resp = await self._client.get(url, headers=headers, raise_for_status=True)
128-
resp_dict = await resp.json()
127+
resp = await self._client.get(url, headers=headers)
128+
# try to get response json for better error message
129+
try:
130+
resp_dict = await resp.json()
131+
if resp.status >= 400:
132+
# if detailed error message is in json response, use as error message
133+
message = resp_dict.get("error", {}).get("message")
134+
if message:
135+
resp.reason = message
136+
# skip, raise_for_status will catch all errors in finally block
137+
except Exception:
138+
pass
139+
finally:
140+
resp.raise_for_status()
129141

130142
# Remove trailing period from PSC DNS name.
131143
psc_dns = resp_dict.get("pscDnsName")
@@ -175,10 +187,20 @@ async def _get_client_certificate(
175187
"useMetadataExchange": self._use_metadata,
176188
}
177189

178-
resp = await self._client.post(
179-
url, headers=headers, json=data, raise_for_status=True
180-
)
181-
resp_dict = await resp.json()
190+
resp = await self._client.post(url, headers=headers, json=data)
191+
# try to get response json for better error message
192+
try:
193+
resp_dict = await resp.json()
194+
if resp.status >= 400:
195+
# if detailed error message is in json response, use as error message
196+
message = resp_dict.get("error", {}).get("message")
197+
if message:
198+
resp.reason = message
199+
# skip, raise_for_status will catch all errors in finally block
200+
except Exception:
201+
pass
202+
finally:
203+
resp.raise_for_status()
182204

183205
return (resp_dict["caCert"], resp_dict["pemCertificateChain"])
184206

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ pytest-asyncio==0.24.0
66
pytest-cov==6.0.0
77
pytest-aiohttp==1.0.5
88
SQLAlchemy[asyncio]==2.0.36
9+
aioresponses==0.7.7

tests/unit/test_client.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
import json
1616
from typing import Any, Optional
1717

18-
from aiohttp import web
18+
from aiohttp import ClientResponseError, web
19+
from aioresponses import aioresponses
1920
from mocks import FakeCredentials
2021
import pytest
2122

@@ -138,6 +139,76 @@ async def test__get_metadata_with_psc(
138139
}
139140

140141

142+
async def test__get_metadata_error(
143+
credentials: FakeCredentials,
144+
) -> None:
145+
"""
146+
Test that AlloyDB API error messages are raised for _get_metadata.
147+
"""
148+
# mock AlloyDB API calls with exceptions
149+
client = AlloyDBClient(
150+
alloydb_api_endpoint="https://alloydb.googleapis.com",
151+
quota_project=None,
152+
credentials=credentials,
153+
)
154+
get_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster/instances/my-instance/connectionInfo"
155+
resp_body = {
156+
"error": {
157+
"code": 403,
158+
"message": "AlloyDB API has not been used in project 123456789 before or it is disabled",
159+
}
160+
}
161+
with aioresponses() as mocked:
162+
mocked.get(
163+
get_url,
164+
status=403,
165+
payload=resp_body,
166+
repeat=True,
167+
)
168+
with pytest.raises(ClientResponseError) as exc_info:
169+
await client._get_metadata("my-project", "my-region", "my-cluster", "my-instance")
170+
exc = exc_info.value
171+
assert exc.status == 403
172+
assert (
173+
exc.message
174+
== "AlloyDB API has not been used in project 123456789 before or it is disabled"
175+
)
176+
await client.close()
177+
178+
179+
async def test__get_metadata_error_parsing_json(
180+
credentials: FakeCredentials,
181+
) -> None:
182+
"""
183+
Test that AlloyDB API error messages are raised for _get_metadata when
184+
response JSON fails to be parsed.
185+
"""
186+
# mock AlloyDB API calls with exceptions
187+
client = AlloyDBClient(
188+
alloydb_api_endpoint="https://alloydb.googleapis.com",
189+
quota_project=None,
190+
credentials=credentials,
191+
)
192+
get_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster/instances/my-instance/connectionInfo"
193+
resp_body = ["error"] # invalid json
194+
with aioresponses() as mocked:
195+
mocked.get(
196+
get_url,
197+
status=403,
198+
payload=resp_body,
199+
repeat=True,
200+
)
201+
with pytest.raises(ClientResponseError) as exc_info:
202+
await client._get_metadata("my-project", "my-region", "my-cluster", "my-instance")
203+
exc = exc_info.value
204+
assert exc.status == 403
205+
assert (
206+
exc.message
207+
!= "AlloyDB API has not been used in project 123456789 before or it is disabled"
208+
)
209+
await client.close()
210+
211+
141212
@pytest.mark.asyncio
142213
async def test__get_client_certificate(
143214
client: Any, credentials: FakeCredentials
@@ -157,6 +228,70 @@ async def test__get_client_certificate(
157228
assert cert_chain[2] == "This is the root cert"
158229

159230

231+
async def test__get_client_certificate_error(
232+
credentials: FakeCredentials,
233+
) -> None:
234+
"""
235+
Test that AlloyDB API error messages are raised for _get_client_certificate.
236+
"""
237+
# mock AlloyDB API calls with exceptions
238+
client = AlloyDBClient(
239+
alloydb_api_endpoint="https://alloydb.googleapis.com",
240+
quota_project=None,
241+
credentials=credentials,
242+
)
243+
post_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster:generateClientCertificate"
244+
resp_body = {
245+
"error": {
246+
"code": 404,
247+
"message": "The AlloyDB instance does not exist.",
248+
}
249+
}
250+
with aioresponses() as mocked:
251+
mocked.post(
252+
post_url,
253+
status=404,
254+
payload=resp_body,
255+
repeat=True,
256+
)
257+
with pytest.raises(ClientResponseError) as exc_info:
258+
await client._get_client_certificate("my-project", "my-region", "my-cluster", "")
259+
exc = exc_info.value
260+
assert exc.status == 404
261+
assert exc.message == "The AlloyDB instance does not exist."
262+
await client.close()
263+
264+
265+
async def test__get_client_certificate_error_parsing_json(
266+
credentials: FakeCredentials,
267+
) -> None:
268+
"""
269+
Test that AlloyDB API error messages are raised for _get_client_certificate
270+
when response JSON fails to be parsed.
271+
"""
272+
# mock AlloyDB API calls with exceptions
273+
client = AlloyDBClient(
274+
alloydb_api_endpoint="https://alloydb.googleapis.com",
275+
quota_project=None,
276+
credentials=credentials,
277+
)
278+
post_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster:generateClientCertificate"
279+
resp_body = ["error"] # invalid json
280+
with aioresponses() as mocked:
281+
mocked.post(
282+
post_url,
283+
status=404,
284+
payload=resp_body,
285+
repeat=True,
286+
)
287+
with pytest.raises(ClientResponseError) as exc_info:
288+
await client._get_client_certificate("my-project", "my-region", "my-cluster", "")
289+
exc = exc_info.value
290+
assert exc.status == 404
291+
assert exc.message != "The AlloyDB instance does not exist."
292+
await client.close()
293+
294+
160295
@pytest.mark.asyncio
161296
async def test_AlloyDBClient_init_(credentials: FakeCredentials) -> None:
162297
"""

0 commit comments

Comments
 (0)