8000 feat: changes how REST errors constructed. · atulep/python-api-core@04b5775 · GitHub
[go: up one dir, main page]

Skip to content

Commit 04b5775

Browse files
committed
feat: changes how REST errors constructed.
Also, adds more tests for gRPC and REST.
1 parent 288019c commit 04b5775

File tree

4 files changed

+88
-71
lines changed

4 files changed

+88
-71
lines changed

google/api_core/exceptions.py

Lines changed: 50 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@
2222
from __future__ import unicode_literals
2323

2424
import http.client
25-
from google import rpc
26-
from google.rpc import status_pb2
2725
from google.rpc import error_details_pb2
28-
from google.protobuf import json_format
29-
import pprint
30-
import json
3126

3227
try:
3328
import grpc
@@ -124,11 +119,12 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
124119
This may be ``None`` if the exception does not match up to a gRPC error.
125120
"""
126121

127-
def __init__(self, message, errors=(), response=None):
122+
def __init__(self, message, errors=(), details=(), response=None):
128123
super(GoogleAPICallError, self).__init__(message)
129124
self.message = message
130125
"""str: The exception message."""
131126
self._errors = errors
127+
self._details = details
132128
self._response = response
133129

134130
def __str__(self):
@@ -143,64 +139,18 @@ def errors(self):
143139
"""
144140
return list(self._errors)
145141

146-
def _parse_status(self, rpc_call) -> status_pb2.Status:
147-
if grpc and isinstance(rpc_call, grpc.Call):
148-
return rpc_status.from_call(rpc_call)
149-
if not isinstance(rpc_call, dict):
150-
return None
151-
# Per HTTP mapping guide, rpc Status should be in
152-
# error field of the response object, unless it's a
153-
# v1 format.
154-
# Ref:
155-
# https://cloud.google.com/apis/design/errors#http_mapping
156-
if rpc_call.get("error", None) is None:
157-
# v1 error format, no status.
158-
return None
159-
status = rpc_call["error"]
160-
status_pb = status_pb2.Status()
161-
json_string = json.dumps(status)
162-
json_format.Parse(json_string, status_pb)
163-
return status_pb
164-
165142
@property
166143
def error_details(self):
167-
"""Detailed error information.
144+
"""Information contained in google.rpc.status.details.
145+
146+
Reference:
147+
https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto
148+
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
168149
169150
Returns:
170-
A list of additional error details.
151+
Sequence[Any]: A list of structured objects from error_details.proto
171152
"""
172-
if getattr(self, "_error_details", None):
173-
return self._error_details
174-
error_details = []
175-
possible_errors = [
176-
error_details_pb2.BadRequest,
177-
error_details_pb2.PreconditionFailure,
178-
error_details_pb2.QuotaFailure,
179-
error_details_pb2.ErrorInfo,
180-
error_details_pb2.RetryInfo,
181-
error_details_pb2.ResourceInfo,
182-
error_details_pb2.RequestInfo,
183-
error_details_pb2.DebugInfo,
184-
error_details_pb2.Help,
185-
error_details_pb2.LocalizedMessage,
186-
]
187-
for rpc_error in self._errors:
188-
status = self._parse_status(rpc_error)
189-
if status is None:
190-
continue
191-
for detail in status.details:
192-
matched_detail_cls = list(
193-
filter(lambda x: detail.Is(x.DESCRIPTOR), possible_errors)
194-
)
195-
# If nothing matched, use detail directly.
196-
if len(matched_detail_cls) == 0:
197-
info = detail
198-
else:
199-
info = matched_detail_cls[0]()
200-
detail.Unpack(info)
201-
error_details.append(info)
202-
self._error_details = error_details
203-
return error_details
153+
return list(self._details)
204154

205155
@property
206156
def response(self):
@@ -475,13 +425,15 @@ def from_http_response(response):
475425

476426
error_message = payload.get("error", {}).get("message", "unknown error")
477427
errors = payload.get("error", {}).get("errors", ())
428+
# In JSON, details are already formatted in developer-friendly way.
429+
details = payload.get("error", {}).get("details", ())
478430

479431
message = "{method} {url}: {error}".format(
480432
method=response.request.method, url=response.request.url, error=error_message
481433
)
482434

483435
exception = from_http_status(
484-
response.status_code, message, errors=errors, response=response
436+
response.status_code, message, errors=errors, details=details, response=response
485437
)
486438
return exception
487439

@@ -528,6 +480,41 @@ def _is_informative_grpc_error(rpc_exc):
528480
return hasattr(rpc_exc, "code") and hasattr(rpc_exc, "details")
529481

530482

483+
def _parse_grpc_error_details(rpc_exc):
484+
if not rpc_status:
485+
return []
486+
if not isinstance(rpc_exc, grpc.Call):
487+
return []
488+
status = rpc_status.from_call(rpc_exc)
489+
if not status:
490+
return []
491+
possible_errors = [
492+
error_details_pb2.BadRequest,
493+
error_details_pb2.PreconditionFailure,
494+
error_details_pb2.QuotaFailure,
495+
error_details_pb2.ErrorInfo,
496+
error_details_pb2.RetryInfo,
497+
error_details_pb2.ResourceInfo,
498+
error_details_pb2.RequestInfo,
499+
error_details_pb2.DebugInfo,
500+
error_details_pb2.Help,
501+
error_details_pb2.LocalizedMessage,
502+
]
503+
error_details = []
504+
for detail in status.details:
505+
matched_detail_cls = list(
506+
filter(lambda x: detail.Is(x.DESCRIPTOR), possible_errors)
507+
)
508+
# If nothing matched, use detail directly.
509+
if len(matched_detail_cls) == 0:
510+
info = detail
511+
else:
512+
info = matched_detail_cls[0]()
513+
detail.Unpack(info)
514+
error_details.append(info)
515+
return error_details
516+
517+
531518
def from_grpc_error(rpc_exc):
532519
"""Create a :class:`GoogleAPICallError` from a :class:`grpc.RpcError`.
533520
@@ -542,7 +529,9 @@ def from_grpc_error(rpc_exc):
542529
# However, check for grpc.RpcError breaks backward compatibility.
543530
if isinstance(rpc_exc, grpc.Call) or _is_informative_grpc_error(rpc_exc):
544531
return from_grpc_status(
545-
rpc_exc.code(), rpc_exc.details(), errors=(rpc_exc,), response=rpc_exc
532+
rpc_exc.code(), rpc_exc.details(), errors=(rpc_exc,),
533+
details=_parse_grpc_error_details(rpc_exc),
534+
response=rpc_exc
546535
)
547536
else:
548537
return GoogleAPICallError(str(rpc_exc), errors=(rpc_exc,), response=rpc_exc)

tests/asyncio/test_grpc_helpers_async.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ def code(self):
3333
def details(self):
3434
return None
3535

36+
def trailing_metadata(self):
37+
return None
3638

3739
@pytest.mark.asyncio
3840
async def test_wrap_unary_errors():

tests/unit/test_exceptions.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@
1414

1515
import http.client
1616
import json
17+
from google.rpc import status_pb2
1718
from google.rpc.status_pb2 import Status
1819

1920
import grpc
2021
import mock
2122
import requests
2223
from google.rpc import error_details_pb2
24+
from google.rpc import code_pb2
2325
from google.protobuf import any_pb2, json_format
2426
from grpc_status import rpc_status
25-
2627
from google.api_core import exceptions
27-
28+
from google.protobuf import text_format
2829

2930
def test_create_google_cloud_error():
3031
exception = exceptions.GoogleAPICallError("Testing")
@@ -37,11 +38,8 @@ def test_create_google_cloud_error():
3738

3839
def test_create_google_cloud_error_with_args():
3940
error = {
40-
"domain": "global",
41-
"location": "test",
42-
"locationType": "testing",
41+
"code": 600,
4342
"message": "Testing",
44-
"reason": "test",
4543
}
4644
response = mock.sentinel.response
4745
exception = exceptions.GoogleAPICallError("Testing", [error], response=response)
@@ -230,29 +228,33 @@ def test_from_grpc_error_non_call():
230228
assert exception.response == error
231229

232230

233-
def test_error_details_from_rest_response():
231+
def create_bad_request_details():
234232
bad_request_details = error_details_pb2.BadRequest()
235233
field_violation = bad_request_details.field_violations.add()
236234
field_violation.field = "document.content"
237235
field_violation.description = "Must have some text content to annotate."
238236
status_detail = any_pb2.Any()
239237
status_detail.Pack(bad_request_details)
238+
return status_detail
239+
240240

241+
def test_error_details_from_rest_response():
242+
bad_request_detail = create_bad_request_details()
241243
status = rpc_status.status_pb2.Status()
242244
status.code = 3
243245
status.message = (
244246
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
245247
)
246-
status.details.append(status_detail)
248+
status.details.append(bad_request_detail)
247249

248-
# See schema in https://cloud.google.com/apis/design/errors#http_mapping
250+
# See JSON schema in https://cloud.google.com/apis/design/errors#http_mapping
249251
http_response = make_response(
250252
json.dumps({"error": json.loads(json_format.MessageToJson(status))}).encode(
251253
"utf-8"
252254
)
253255
)
254256
exception = exceptions.from_http_response(http_response)
255-
want_error_details = [bad_request_details]
257+
want_error_details = [json.loads(json_format.MessageToJson(bad_request_detail))]
256258
assert want_error_details == exception.error_details
257259

258260

@@ -264,3 +266,24 @@ def test_error_details_from_v1_rest_response():
264266
)
265267
exception = exceptions.from_http_response(response)
266268
assert exception.error_details == []
269+
270+
271+
def test_error_details_from_grpc_response():
272+
status = rpc_status.status_pb2.Status()
273+
status.code = 3
274+
status.message = (
275+
"3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
276+
)
277+
status_detail = create_bad_request_details()
278+
status.details.append(status_detail)
279+
280+
# Actualy error doesn't matter as long as its grpc.Call,
281+
# because from_call is mocked.
282+
error = mock.create_autospec(grpc.Call, instance=True)
283+
with mock.patch('grpc_status.rpc_status.from_call') as m:
284+
m.return_value = status
285+
exception = exceptions.from_grpc_error(error)
286+
287+
bad_request_detail = error_details_pb2.BadRequest()
288+
status_detail.Unpack(bad_request_detail)
289+
assert exception.error_details == [bad_request_detail]

tests/unit/test_grpc_helpers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def code(self):
5252
def details(self):
5353
return None
5454

55+
def trailing_metadata(self):
56+
return None
57+
5558

5659
def test_wrap_unary_errors():
5760
grpc_error = RpcErrorImpl(grpc.StatusCode.INVALID_ARGUMENT)

0 commit comments

Comments
 (0)
0