Skip to content

Commit 58ef2d2

Browse files
feat: improve aiohttp client error messages (#400)
This change is similar to GoogleCloudPlatform/cloud-sql-python-connector#1201, except for AlloyDB.
1 parent 5255fbb commit 58ef2d2

File tree

3 files changed

+166
-6
lines changed

3 files changed

+166
-6
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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import json
1616
from typing import Any, Optional
1717

18+
from aiohttp import ClientResponseError
1819
from aiohttp import web
20+
from aioresponses import aioresponses
1921
from mocks import FakeCredentials
2022
import pytest
2123

@@ -138,6 +140,75 @@ async def test__get_metadata_with_psc(
138140
}
139141

140142

143+
async def test__get_metadata_error(
144+
credentials: FakeCredentials,
145+
) -> None:
146+
"""
147+
Test that AlloyDB API error messages are raised for _get_metadata.
148+
"""
149+
# mock AlloyDB API calls with exceptions
150+
client = AlloyDBClient(
151+
alloydb_api_endpoint="https://alloydb.googleapis.com",
152+
quota_project=None,
153+
credentials=credentials,
154+
)
155+
get_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster/instances/my-instance/connectionInfo"
156+
resp_body = {
157+
"error": {
158+
"code": 403,
159+
"message": "AlloyDB API has not been used in project 123456789 before or it is disabled",
160+
}
161+
}
162+
with aioresponses() as mocked:
163+
mocked.get(
164+
get_url,
165+
status=403,
166+
payload=resp_body,
167+
repeat=True,
168+
)
169+
with pytest.raises(ClientResponseError) as exc_info:
170+
await client._get_metadata(
171+
"my-project", "my-region", "my-cluster", "my-instance"
172+
)
173+
assert exc_info.value.status == 403
174+
assert (
175+
exc_info.value.message
176+
== "AlloyDB API has not been used in project 123456789 before or it is disabled"
177+
)
178+
await client.close()
179+
180+
181+
async def test__get_metadata_error_parsing_json(
182+
credentials: FakeCredentials,
183+
) -> None:
184+
"""
185+
Test that aiohttp default error messages are raised when _get_metadata gets
186+
a bad JSON response.
187+
"""
188+
# mock AlloyDB API calls with exceptions
189+
client = AlloyDBClient(
190+
alloydb_api_endpoint="https://alloydb.googleapis.com",
191+
quota_project=None,
192+
credentials=credentials,
193+
)
194+
get_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster/instances/my-instance/connectionInfo"
195+
resp_body = ["error"] # invalid json
196+
with aioresponses() as mocked:
197+
mocked.get(
198+
get_url,
199+
status=403,
200+
payload=resp_body,
201+
repeat=True,
202+
)
203+
with pytest.raises(ClientResponseError) as exc_info:
204+
await client._get_metadata(
205+
"my-project", "my-region", "my-cluster", "my-instance"
206+
)
207+
assert exc_info.value.status == 403
208+
assert exc_info.value.message == "Forbidden"
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,72 @@ 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(
259+
"my-project", "my-region", "my-cluster", ""
260+
)
261+
assert exc_info.value.status == 404
262+
assert exc_info.value.message == "The AlloyDB instance does not exist."
263+
await client.close()
264+
265+
266+
async def test__get_client_certificate_error_parsing_json(
267+
credentials: FakeCredentials,
268+
) -> None:
269+
"""
270+
Test that aiohttp default error messages are raised when
271+
_get_client_certificate gets a bad JSON response.
272+
"""
273+
# mock AlloyDB API calls with exceptions
274+
client = AlloyDBClient(
275+
alloydb_api_endpoint="https://alloydb.googleapis.com",
276+
quota_project=None,
277+
credentials=credentials,
278+
)
279+
post_url = "https://alloydb.googleapis.com/v1beta/projects/my-project/locations/my-region/clusters/my-cluster:generateClientCertificate"
280+
resp_body = ["error"] # invalid json
281+
with aioresponses() as mocked:
282+
mocked.post(
283+
post_url,
284+
status=404,
285+
payload=resp_body,
286+
repeat=True,
287+
)
288+
with pytest.raises(ClientResponseError) as exc_info:
289+
await client._get_client_certificate(
290+
"my-project", "my-region", "my-cluster", ""
291+
)
292+
assert exc_info.value.status == 404
293+
assert exc_info.value.message == "Not Found"
294+
await client.close()
295+
296+
160297
@pytest.mark.asyncio
161298
async def test_AlloyDBClient_init_(credentials: FakeCredentials) -> None:
162299
"""

0 commit comments

Comments
 (0)