Skip to content

Commit ef6f0fc

Browse files
authored
feat: add 'GoogleAPICallError.error_details' property (googleapis#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)