Skip to content

Commit cc46aa6

Browse files
atuleptseaver
andauthored
feat: add support for 'error_info' (#315)
* feat: Adds support for error_info. * chore: fixes pytype. Co-authored-by: Tres Seaver <[email protected]>
1 parent 479d6a7 commit cc46aa6

File tree

2 files changed

+116
-17
lines changed

2 files changed

+116
-17
lines changed

google/api_core/exceptions.py

+64-7
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
104104
details (Sequence[Any]): An optional list of objects defined in google.rpc.error_details.
105105
response (Union[requests.Request, grpc.Call]): The response or
106106
gRPC call metadata.
107+
error_info (Union[error_details_pb2.ErrorInfo, None]): An optional object containing error info
108+
(google.rpc.error_details.ErrorInfo).
107109
"""
108110

109111
code: Union[int, None] = None
@@ -122,20 +124,57 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
122124
This may be ``None`` if the exception does not match up to a gRPC error.
123125
"""
124126

125-
def __init__(self, message, errors=(), details=(), response=None):
127+
def __init__(self, message, errors=(), details=(), response=None, error_info=None):
126128
super(GoogleAPICallError, self).__init__(message)
127129
self.message = message
128130
"""str: The exception message."""
129131
self._errors = errors
130132
self._details = details
131133
self._response = response
134+
self._error_info = error_info
132135

133136
def __str__(self):
134137
if self.details:
135138
return "{} {} {}".format(self.code, self.message, self.details)
136139
else:
137140
return "{} {}".format(self.code, self.message)
138141

142+
@property
143+
def reason(self):
144+
"""The reason of the error.
145+
146+
Reference:
147+
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto#L112
148+
149+
Returns:
150+
Union[str, None]: An optional string containing reason of the error.
151+
"""
152+
return self._error_info.reason if self._error_info else None
153+
154+
@property
155+
def domain(self):
156+
"""The logical grouping to which the "reason" belongs.
157+
158+
Reference:
159+
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto#L112
160+
161+
Returns:
162+
Union[str, None]: An optional string containing a logical grouping to which the "reason" belongs.
163+
"""
164+
return self._error_info.domain if self._error_info else None
165+
166+
@property
167+
def metadata(self):
168+
"""Additional structured details about this error.
169+
170+
Reference:
171+
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto#L112
172+
173+
Returns:
174+
Union[Dict[str, str], None]: An optional object containing structured details about the error.
175+
"""
176+
return self._error_info.metadata if self._error_info else None
177+
139178
@property
140179
def errors(self):
141180
"""Detailed error information.
@@ -433,13 +472,26 @@ def from_http_response(response):
433472
errors = payload.get("error", {}).get("errors", ())
434473
# In JSON, details are already formatted in developer-friendly way.
435474
details = payload.get("error", {}).get("details", ())
475+
error_info = list(
476+
filter(
477+
lambda detail: detail.get("@type", "")
478+
== "type.googleapis.com/google.rpc.ErrorInfo",
479+
details,
480+
)
481+
)
482+
error_info = error_info[0] if error_info else None
436483

437484
message = "{method} {url}: {error}".format(
438-
method=response.request.method, url=response.request.url, error=error_message
485+
method=response.request.method, url=response.request.url, error=error_message,
439486
)
440487

441488
exception = from_http_status(
442-
response.status_code, message, errors=errors, details=details, response=response
489+
response.status_code,
490+
message,
491+
errors=errors,
492+
details=details,
493+
response=response,
494+
error_info=error_info,
443495
)
444496
return exception
445497

@@ -490,10 +542,10 @@ def _parse_grpc_error_details(rpc_exc):
490542
try:
491543
status = rpc_status.from_call(rpc_exc)
492544
except NotImplementedError: # workaround
493-
return []
545+
return [], None
494546

495547
if not status:
496-
return []
548+
return [], None
497549

498550
possible_errors = [
499551
error_details_pb2.BadRequest,
@@ -507,6 +559,7 @@ def _parse_grpc_error_details(rpc_exc):
507559
error_details_pb2.Help,
508560
error_details_pb2.LocalizedMessage,
509561
]
562+
error_info = None
510563
error_details = []
511564
for detail in status.details:
512565
matched_detail_cls = list(
@@ -519,7 +572,9 @@ def _parse_grpc_error_details(rpc_exc):
519572
info = matched_detail_cls[0]()
520573
detail.Unpack(info)
521574
error_details.append(info)
522-
return error_details
575+
if isinstance(info, error_details_pb2.ErrorInfo):
576+
error_info = info
577+
return error_details, error_info
523578

524579

525580
def from_grpc_error(rpc_exc):
@@ -535,12 +590,14 @@ def from_grpc_error(rpc_exc):
535590
# NOTE(lidiz) All gRPC error shares the parent class grpc.RpcError.
536591
# However, check for grpc.RpcError breaks backward compatibility.
537592
if isinstance(rpc_exc, grpc.Call) or _is_informative_grpc_error(rpc_exc):
593+
details, err_info = _parse_grpc_error_details(rpc_exc)
538594
return from_grpc_status(
539595
rpc_exc.code(),
540596
rpc_exc.details(),
541597
errors=(rpc_exc,),
542-
details=_parse_grpc_error_details(rpc_exc),
598+
details=details,
543599
response=rpc_exc,
600+
error_info=err_info,
544601
)
545602
else:
546603
return GoogleAPICallError(str(rpc_exc), errors=(rpc_exc,), response=rpc_exc)

tests/unit/test_exceptions.py

+52-10
Original file line numberDiff line numberDiff line change
@@ -275,31 +275,56 @@ def create_bad_request_details():
275275
return status_detail
276276

277277

278+
def create_error_info_details():
279+
info = error_details_pb2.ErrorInfo(
280+
reason="SERVICE_DISABLED",
281+
domain="googleapis.com",
282+
metadata={
283+
"consumer": "projects/455411330361",
284+
"service": "translate.googleapis.com",
285+
},
286+
)
287+
status_detail = any_pb2.Any()
288+
status_detail.Pack(info)
289+
return status_detail
290+
291+
278292
def test_error_details_from_rest_response():
279293
bad_request_detail = create_bad_request_details()
294+
error_info_detail = create_error_info_details()
280295
status = status_pb2.Status()
281296
status.code = 3
282297
status.message = (
283298
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
284299
)
285300
status.details.append(bad_request_detail)
301+
status.details.append(error_info_detail)
286302

287303
# See JSON schema in https://cloud.google.com/apis/design/errors#http_mapping
288304
http_response = make_response(
289-
json.dumps({"error": json.loads(json_format.MessageToJson(status))}).encode(
290-
"utf-8"
291-
)
305+
json.dumps(
306+
{"error": json.loads(json_format.MessageToJson(status, sort_keys=True))}
307+
).encode("utf-8")
292308
)
293309
exception = exceptions.from_http_response(http_response)
294-
want_error_details = [json.loads(json_format.MessageToJson(bad_request_detail))]
310+
want_error_details = [
311+
json.loads(json_format.MessageToJson(bad_request_detail)),
312+
json.loads(json_format.MessageToJson(error_info_detail)),
313+
]
295314
assert want_error_details == exception.details
315+
296316
# 404 POST comes from make_response.
297317
assert str(exception) == (
298318
"404 POST https://example.com/: 3 INVALID_ARGUMENT:"
299319
" One of content, or gcs_content_uri must be set."
300320
" [{'@type': 'type.googleapis.com/google.rpc.BadRequest',"
301-
" 'fieldViolations': [{'field': 'document.content',"
302-
" 'description': 'Must have some text content to annotate.'}]}]"
321+
" 'fieldViolations': [{'description': 'Must have some text content to annotate.',"
322+
" 'field': 'document.content'}]},"
323+
" {'@type': 'type.googleapis.com/google.rpc.ErrorInfo',"
324+
" 'domain': 'googleapis.com',"
325+
" 'metadata': {'consumer': 'projects/455411330361',"
326+
" 'service': 'translate.googleapis.com'},"
327+
" 'reason': 'SERVICE_DISABLED'}]"
303328
)
304329

305330

@@ -311,6 +336,11 @@ def test_error_details_from_v1_rest_response():
311336
)
312337
exception = exceptions.from_http_response(response)
313338
assert exception.details == []
339+
assert (
340+
exception.reason is None
341+
and exception.domain is None
342+
and exception.metadata is None
343+
)
314344

315345

316346
@pytest.mark.skipif(grpc is None, reason="gRPC not importable")
@@ -320,8 +350,10 @@ def test_error_details_from_grpc_response():
320350
status.message = (
321351
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
322352
)
323-
status_detail = create_bad_request_details()
324-
status.details.append(status_detail)
353+
status_br_detail = create_bad_request_details()
354+
status_ei_detail = create_error_info_details()
355+
status.details.append(status_br_detail)
356+
status.details.append(status_ei_detail)
325357

326358
# Actualy error doesn't matter as long as its grpc.Call,
327359
# because from_call is mocked.
@@ -331,8 +363,13 @@ def test_error_details_from_grpc_response():
331363
exception = exceptions.from_grpc_error(error)
332364

333365
bad_request_detail = error_details_pb2.BadRequest()
334-
status_detail.Unpack(bad_request_detail)
335-
assert exception.details == [bad_request_detail]
366+
error_info_detail = error_details_pb2.ErrorInfo()
367+
status_br_detail.Unpack(bad_request_detail)
368+
status_ei_detail.Unpack(error_info_detail)
369+
assert exception.details == [bad_request_detail, error_info_detail]
370+
assert exception.reason == error_info_detail.reason
371+
assert exception.domain == error_info_detail.domain
372+
assert exception.metadata == error_info_detail.metadata
336373

337374

338375
@pytest.mark.skipif(grpc is None, reason="gRPC not importable")
@@ -351,3 +388,8 @@ def test_error_details_from_grpc_response_unknown_error():
351388
m.return_value = status
352389
exception = exceptions.from_grpc_error(error)
353390
assert exception.details == [status_detail]
391+
assert (
392+
exception.reason is None
393+
and exception.domain is None
394+
and exception.metadata is None
395+
)

0 commit comments

Comments
 (0)