Skip to content

Commit ef6f0fc

Browse files
authored
feat: add 'GoogleAPICallError.error_details' property (#286)
Based on 'google.rpc.status.details'.
1 parent 09cf285 commit ef6f0fc

File tree

6 files changed

+166
-12
lines changed

6 files changed

+166
-12
lines changed

google/api_core/exceptions.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@
2525
from typing import Dict
2626
from typing import Union
2727

28+
from google.rpc import error_details_pb2
29+
2830
try:
2931
import grpc
32+
from grpc_status import rpc_status
3033
except ImportError: # pragma: NO COVER
3134
grpc = None
35+
rpc_status = None
3236

3337
# Lookup tables for mapping exceptions from HTTP and gRPC transports.
3438
# Populated by _GoogleAPICallErrorMeta
@@ -97,6 +101,7 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
97101
Args:
98102
message (str): The exception message.
99103
errors (Sequence[Any]): An optional list of error details.
104+
details (Sequence[Any]): An optional list of objects defined in google.rpc.error_details.
100105
response (Union[requests.Request, grpc.Call]): The response or
101106
gRPC call metadata.
102107
"""
@@ -117,15 +122,19 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
117122
This may be ``None`` if the exception does not match up to a gRPC error.
118123
"""
119124

120-
def __init__(self, message, errors=(), response=None):
125+
def __init__(self, message, errors=(), details=(), response=None):
121126
super(GoogleAPICallError, self).__init__(message)
122127
self.message = message
123128
"""str: The exception message."""
124129
self._errors = errors
130+
self._details = details
125131
self._response = response
126132

127133
def __str__(self):
128-
return "{} {}".format(self.code, self.message)
134+
if self.details:
135+
return "{} {} {}".format(self.code, self.message, self.details)
136+
else:
137+
return "{} {}".format(self.code, self.message)
129138

130139
@property
131140
def errors(self):
@@ -136,6 +145,19 @@ def errors(self):
136145
"""
137146
return list(self._errors)
138147

148+
@property
149+
def details(self):
150+
"""Information contained in google.rpc.status.details.
151+
152+
Reference:
153+
https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto
154+
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
155+
156+
Returns:
157+
Sequence[Any]: A list of structured objects from error_details.proto
158+
"""
159+
return list(self._details)
160+
139161
@property
140162
def response(self):
141163
"""Optional[Union[requests.Request, grpc.Call]]: The response or
@@ -409,13 +431,15 @@ def from_http_response(response):
409431

410432
error_message = payload.get("error", {}).get("message", "unknown error")
411433
errors = payload.get("error", {}).get("errors", ())
434+
# In JSON, details are already formatted in developer-friendly way.
435+
details = payload.get("error", {}).get("details", ())
412436

413437
message = "{method} {url}: {error}".format(
414438
method=response.request.method, url=response.request.url, error=error_message
415439
)
416440

417441
exception = from_http_status(
418-
response.status_code, message, errors=errors, response=response
442+
response.status_code, message, errors=errors, details=details, response=response
419443
)
420444
return exception
421445

@@ -462,6 +486,37 @@ def _is_informative_grpc_error(rpc_exc):
462486
return hasattr(rpc_exc, "code") and hasattr(rpc_exc, "details")
463487

464488

489+
def _parse_grpc_error_details(rpc_exc):
490+
status = rpc_status.from_call(rpc_exc)
491+
if not status:
492+
return []
493+
possible_errors = [
494+
error_details_pb2.BadRequest,
495+
error_details_pb2.PreconditionFailure,
496+
error_details_pb2.QuotaFailure,
497+
error_details_pb2.ErrorInfo,
498+
error_details_pb2.RetryInfo,
499+
error_details_pb2.ResourceInfo,
500+
error_details_pb2.RequestInfo,
501+
error_details_pb2.DebugInfo,
502+
error_details_pb2.Help,
503+
error_details_pb2.LocalizedMessage,
504+
]
505+
error_details = []
506+
for detail in status.details:
507+
matched_detail_cls = list(
508+
filter(lambda x: detail.Is(x.DESCRIPTOR), possible_errors)
509+
)
510+
# If nothing matched, use detail directly.
511+
if len(matched_detail_cls) == 0:
512+
info = detail
513+
else:
514+
info = matched_detail_cls[0]()
515+
detail.Unpack(info)
516+
error_details.append(info)
517+
return error_details
518+
519+
465520
def from_grpc_error(rpc_exc):
466521
"""Create a :class:`GoogleAPICallError` from a :class:`grpc.RpcError`.
467522
@@ -476,7 +531,11 @@ def from_grpc_error(rpc_exc):
476531
# However, check for grpc.RpcError breaks backward compatibility.
477532
if isinstance(rpc_exc, grpc.Call) or _is_informative_grpc_error(rpc_exc):
478533
return from_grpc_status(
479-
rpc_exc.code(), rpc_exc.details(), errors=(rpc_exc,), response=rpc_exc
534+
rpc_exc.code(),
535+
rpc_exc.details(),
536+
errors=(rpc_exc,),
537+
details=_parse_grpc_error_details(rpc_exc),
538+
response=rpc_exc,
480539
)
481540
else:
482541
return GoogleAPICallError(str(rpc_exc), errors=(rpc_exc,), response=rpc_exc)

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@
2929
# 'Development Status :: 5 - Production/Stable'
3030
release_status = "Development Status :: 5 - Production/Stable"
3131
dependencies = [
32-
"googleapis-common-protos >= 1.6.0, < 2.0dev",
32+
"googleapis-common-protos >= 1.52.0, < 2.0dev",
3333
"protobuf >= 3.12.0",
3434
"google-auth >= 1.25.0, < 3.0dev",
3535
"requests >= 2.18.0, < 3.0.0dev",
3636
"setuptools >= 40.3.0",
3737
]
3838
extras = {
39-
"grpc": "grpcio >= 1.33.2, < 2.0dev",
39+
"grpc": ["grpcio >= 1.33.2, < 2.0dev", "grpcio-status >= 1.33.2, < 2.0dev"],
4040
"grpcgcp": "grpcio-gcp >= 0.2.2",
4141
"grpcio-gcp": "grpcio-gcp >= 0.2.2",
4242
}

testing/constraints-3.6.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#
66
# e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev",
77
# Then this file should have foo==1.14.0
8-
googleapis-common-protos==1.6.0
8+
googleapis-common-protos==1.52.0
99
protobuf==3.12.0
1010
google-auth==1.25.0
1111
requests==2.18.0
@@ -14,3 +14,4 @@ packaging==14.3
1414
grpcio==1.33.2
1515
grpcio-gcp==0.2.2
1616
grpcio-gcp==0.2.2
17+
grpcio-status==1.33.2

tests/asyncio/test_grpc_helpers_async.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def code(self):
4242
def details(self):
4343
return None
4444

45+
def trailing_metadata(self):
46+
return None
47+
4548

4649
@pytest.mark.asyncio
4750
async def test_wrap_unary_errors():

tests/unit/test_exceptions.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121

2222
try:
2323
import grpc
24+
from grpc_status import rpc_status
2425
except ImportError:
25-
grpc = None
26+
grpc = rpc_status = None
2627

2728
from google.api_core import exceptions
29+
from google.protobuf import any_pb2, json_format
30+
from google.rpc import error_details_pb2, status_pb2
2831

2932

3033
def test_create_google_cloud_error():
@@ -38,11 +41,8 @@ def test_create_google_cloud_error():
3841

3942
def test_create_google_cloud_error_with_args():
4043
error = {
41-
"domain": "global",
42-
"location": "test",
43-
"locationType": "testing",
44+
"code": 600,
4445
"message": "Testing",
45-
"reason": "test",
4646
}
4747
response = mock.sentinel.response
4848
exception = exceptions.GoogleAPICallError("Testing", [error], response=response)
@@ -235,3 +235,91 @@ def test_from_grpc_error_non_call():
235235
assert exception.message == message
236236
assert exception.errors == [error]
237237
assert exception.response == error
238+
239+
240+
def create_bad_request_details():
241+
bad_request_details = error_details_pb2.BadRequest()
242+
field_violation = bad_request_details.field_violations.add()
243+
field_violation.field = "document.content"
244+
field_violation.description = "Must have some text content to annotate."
245+
status_detail = any_pb2.Any()
246+
status_detail.Pack(bad_request_details)
247+
return status_detail
248+
249+
250+
def test_error_details_from_rest_response():
251+
bad_request_detail = create_bad_request_details()
252+
status = status_pb2.Status()
253+
status.code = 3
254+
status.message = (
255+
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
256+
)
257+
status.details.append(bad_request_detail)
258+
259+
# See JSON schema in https://cloud.google.com/apis/design/errors#http_mapping
260+
http_response = make_response(
261+
json.dumps({"error": json.loads(json_format.MessageToJson(status))}).encode(
262+
"utf-8"
263+
)
264+
)
265+
exception = exceptions.from_http_response(http_response)
266+
want_error_details = [json.loads(json_format.MessageToJson(bad_request_detail))]
267+
assert want_error_details == exception.details
268+
# 404 POST comes from make_response.
269+
assert str(exception) == (
270+
"404 POST https://example.com/: 3 INVALID_ARGUMENT:"
271+
" One of content, or gcs_content_uri must be set."
272+
" [{'@type': 'type.googleapis.com/google.rpc.BadRequest',"
273+
" 'fieldViolations': [{'field': 'document.content',"
274+
" 'description': 'Must have some text content to annotate.'}]}]"
275+
)
276+
277+
278+
def test_error_details_from_v1_rest_response():
279+
response = make_response(
280+
json.dumps(
281+
{"error": {"message": "\u2019 message", "errors": ["1", "2"]}}
282+
).encode("utf-8")
283+
)
284+
exception = exceptions.from_http_response(response)
285+
assert exception.details == []
286+
287+
288+
@pytest.mark.skipif(grpc is None, reason="gRPC not importable")
289+
def test_error_details_from_grpc_response():
290+
status = rpc_status.status_pb2.Status()
291+
status.code = 3
292+
status.message = (
293+
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
294+
)
295+
status_detail = create_bad_request_details()
296+
status.details.append(status_detail)
297+
298+
# Actualy error doesn't matter as long as its grpc.Call,
299+
# because from_call is mocked.
300+
error = mock.create_autospec(grpc.Call, instance=True)
301+
with mock.patch("grpc_status.rpc_status.from_call") as m:
302+
m.return_value = status
303+
exception = exceptions.from_grpc_error(error)
304+
305+
bad_request_detail = error_details_pb2.BadRequest()
306+
status_detail.Unpack(bad_request_detail)
307+
assert exception.details == [bad_request_detail]
308+
309+
310+
@pytest.mark.skipif(grpc is None, reason="gRPC not importable")
311+
def test_error_details_from_grpc_response_unknown_error():
312+
status_detail = any_pb2.Any()
313+
314+
status = rpc_status.status_pb2.Status()
315+
status.code = 3
316+
status.message = (
317+
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
318+
)
319+
status.details.append(status_detail)
320+
321+
error = mock.create_autospec(grpc.Call, instance=True)
322+
with mock.patch("grpc_status.rpc_status.from_call") as m:
323+
m.return_value = status
324+
exception = exceptions.from_grpc_error(error)
325+
assert exception.details == [status_detail]

tests/unit/test_grpc_helpers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ def code(self):
5656
def details(self):
5757
return None
5858

59+
def trailing_metadata(self):
60+
return None
61+
5962

6063
def test_wrap_unary_errors():
6164
grpc_error = RpcErrorImpl(grpc.StatusCode.INVALID_ARGUMENT)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy