aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAza Tulepbergenov <aza.tulepber@gmail.com>2021-10-19 11:17:49 -0700
committerGitHub <noreply@github.com>2021-10-19 14:17:49 -0400
commitef6f0fcfdfe771172056e35e3c990998b3b00416 (patch)
treeffe756fb2b0ccf5e3658be59265b8656c17e9061
parent09cf285536fee519106db335670e1560b0846bcf (diff)
downloadpython-api-core-ef6f0fcfdfe771172056e35e3c990998b3b00416.tar.gz
feat: add 'GoogleAPICallError.error_details' property (#286)
Based on 'google.rpc.status.details'.
-rw-r--r--google/api_core/exceptions.py67
-rw-r--r--setup.py4
-rw-r--r--testing/constraints-3.6.txt3
-rw-r--r--tests/asyncio/test_grpc_helpers_async.py3
-rw-r--r--tests/unit/test_exceptions.py98
-rw-r--r--tests/unit/test_grpc_helpers.py3
6 files changed, 166 insertions, 12 deletions
diff --git a/google/api_core/exceptions.py b/google/api_core/exceptions.py
index 2cfc2e4..fdb2109 100644
--- a/google/api_core/exceptions.py
+++ b/google/api_core/exceptions.py
@@ -25,10 +25,14 @@ import http.client
from typing import Dict
from typing import Union
+from google.rpc import error_details_pb2
+
try:
import grpc
+ from grpc_status import rpc_status
except ImportError: # pragma: NO COVER
grpc = None
+ rpc_status = None
# Lookup tables for mapping exceptions from HTTP and gRPC transports.
# Populated by _GoogleAPICallErrorMeta
@@ -97,6 +101,7 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
Args:
message (str): The exception message.
errors (Sequence[Any]): An optional list of error details.
+ details (Sequence[Any]): An optional list of objects defined in google.rpc.error_details.
response (Union[requests.Request, grpc.Call]): The response or
gRPC call metadata.
"""
@@ -117,15 +122,19 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
This may be ``None`` if the exception does not match up to a gRPC error.
"""
- def __init__(self, message, errors=(), response=None):
+ def __init__(self, message, errors=(), details=(), response=None):
super(GoogleAPICallError, self).__init__(message)
self.message = message
"""str: The exception message."""
self._errors = errors
+ self._details = details
self._response = response
def __str__(self):
- return "{} {}".format(self.code, self.message)
+ if self.details:
+ return "{} {} {}".format(self.code, self.message, self.details)
+ else:
+ return "{} {}".format(self.code, self.message)
@property
def errors(self):
@@ -137,6 +146,19 @@ class GoogleAPICallError(GoogleAPIError, metaclass=_GoogleAPICallErrorMeta):
return list(self._errors)
@property
+ def details(self):
+ """Information contained in google.rpc.status.details.
+
+ Reference:
+ https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto
+ https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
+
+ Returns:
+ Sequence[Any]: A list of structured objects from error_details.proto
+ """
+ return list(self._details)
+
+ @property
def response(self):
"""Optional[Union[requests.Request, grpc.Call]]: The response or
gRPC call metadata."""
@@ -409,13 +431,15 @@ def from_http_response(response):
error_message = payload.get("error", {}).get("message", "unknown error")
errors = payload.get("error", {}).get("errors", ())
+ # In JSON, details are already formatted in developer-friendly way.
+ details = payload.get("error", {}).get("details", ())
message = "{method} {url}: {error}".format(
method=response.request.method, url=response.request.url, error=error_message
)
exception = from_http_status(
- response.status_code, message, errors=errors, response=response
+ response.status_code, message, errors=errors, details=details, response=response
)
return exception
@@ -462,6 +486,37 @@ def _is_informative_grpc_error(rpc_exc):
return hasattr(rpc_exc, "code") and hasattr(rpc_exc, "details")
+def _parse_grpc_error_details(rpc_exc):
+ status = rpc_status.from_call(rpc_exc)
+ if not status:
+ return []
+ possible_errors = [
+ error_details_pb2.BadRequest,
+ error_details_pb2.PreconditionFailure,
+ error_details_pb2.QuotaFailure,
+ error_details_pb2.ErrorInfo,
+ error_details_pb2.RetryInfo,
+ error_details_pb2.ResourceInfo,
+ error_details_pb2.RequestInfo,
+ error_details_pb2.DebugInfo,
+ error_details_pb2.Help,
+ error_details_pb2.LocalizedMessage,
+ ]
+ error_details = []
+ for detail in status.details:
+ matched_detail_cls = list(
+ filter(lambda x: detail.Is(x.DESCRIPTOR), possible_errors)
+ )
+ # If nothing matched, use detail directly.
+ if len(matched_detail_cls) == 0:
+ info = detail
+ else:
+ info = matched_detail_cls[0]()
+ detail.Unpack(info)
+ error_details.append(info)
+ return error_details
+
+
def from_grpc_error(rpc_exc):
"""Create a :class:`GoogleAPICallError` from a :class:`grpc.RpcError`.
@@ -476,7 +531,11 @@ def from_grpc_error(rpc_exc):
# However, check for grpc.RpcError breaks backward compatibility.
if isinstance(rpc_exc, grpc.Call) or _is_informative_grpc_error(rpc_exc):
return from_grpc_status(
- rpc_exc.code(), rpc_exc.details(), errors=(rpc_exc,), response=rpc_exc
+ rpc_exc.code(),
+ rpc_exc.details(),
+ errors=(rpc_exc,),
+ details=_parse_grpc_error_details(rpc_exc),
+ response=rpc_exc,
)
else:
return GoogleAPICallError(str(rpc_exc), errors=(rpc_exc,), response=rpc_exc)
diff --git a/setup.py b/setup.py
index d150bc0..ddc5600 100644
--- a/setup.py
+++ b/setup.py
@@ -29,14 +29,14 @@ description = "Google API client core library"
# 'Development Status :: 5 - Production/Stable'
release_status = "Development Status :: 5 - Production/Stable"
dependencies = [
- "googleapis-common-protos >= 1.6.0, < 2.0dev",
+ "googleapis-common-protos >= 1.52.0, < 2.0dev",
"protobuf >= 3.12.0",
"google-auth >= 1.25.0, < 3.0dev",
"requests >= 2.18.0, < 3.0.0dev",
"setuptools >= 40.3.0",
]
extras = {
- "grpc": "grpcio >= 1.33.2, < 2.0dev",
+ "grpc": ["grpcio >= 1.33.2, < 2.0dev", "grpcio-status >= 1.33.2, < 2.0dev"],
"grpcgcp": "grpcio-gcp >= 0.2.2",
"grpcio-gcp": "grpcio-gcp >= 0.2.2",
}
diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt
index 744e991..0c2a07b 100644
--- a/testing/constraints-3.6.txt
+++ b/testing/constraints-3.6.txt
@@ -5,7 +5,7 @@
#
# e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev",
# Then this file should have foo==1.14.0
-googleapis-common-protos==1.6.0
+googleapis-common-protos==1.52.0
protobuf==3.12.0
google-auth==1.25.0
requests==2.18.0
@@ -14,3 +14,4 @@ packaging==14.3
grpcio==1.33.2
grpcio-gcp==0.2.2
grpcio-gcp==0.2.2
+grpcio-status==1.33.2
diff --git a/tests/asyncio/test_grpc_helpers_async.py b/tests/asyncio/test_grpc_helpers_async.py
index 0413abf..3681a40 100644
--- a/tests/asyncio/test_grpc_helpers_async.py
+++ b/tests/asyncio/test_grpc_helpers_async.py
@@ -42,6 +42,9 @@ class RpcErrorImpl(grpc.RpcError, grpc.Call):
def details(self):
return None
+ def trailing_metadata(self):
+ return None
+
@pytest.mark.asyncio
async def test_wrap_unary_errors():
diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py
index 95317db..f6345fe 100644
--- a/tests/unit/test_exceptions.py
+++ b/tests/unit/test_exceptions.py
@@ -21,10 +21,13 @@ import requests
try:
import grpc
+ from grpc_status import rpc_status
except ImportError:
- grpc = None
+ grpc = rpc_status = None
from google.api_core import exceptions
+from google.protobuf import any_pb2, json_format
+from google.rpc import error_details_pb2, status_pb2
def test_create_google_cloud_error():
@@ -38,11 +41,8 @@ def test_create_google_cloud_error():
def test_create_google_cloud_error_with_args():
error = {
- "domain": "global",
- "location": "test",
- "locationType": "testing",
+ "code": 600,
"message": "Testing",
- "reason": "test",
}
response = mock.sentinel.response
exception = exceptions.GoogleAPICallError("Testing", [error], response=response)
@@ -235,3 +235,91 @@ def test_from_grpc_error_non_call():
assert exception.message == message
assert exception.errors == [error]
assert exception.response == error
+
+
+def create_bad_request_details():
+ bad_request_details = error_details_pb2.BadRequest()
+ field_violation = bad_request_details.field_violations.add()
+ field_violation.field = "document.content"
+ field_violation.description = "Must have some text content to annotate."
+ status_detail = any_pb2.Any()
+ status_detail.Pack(bad_request_details)
+ return status_detail
+
+
+def test_error_details_from_rest_response():
+ bad_request_detail = create_bad_request_details()
+ status = status_pb2.Status()
+ status.code = 3
+ status.message = (
+ "3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
+ )
+ status.details.append(bad_request_detail)
+
+ # See JSON schema in https://cloud.google.com/apis/design/errors#http_mapping
+ http_response = make_response(
+ json.dumps({"error": json.loads(json_format.MessageToJson(status))}).encode(
+ "utf-8"
+ )
+ )
+ exception = exceptions.from_http_response(http_response)
+ want_error_details = [json.loads(json_format.MessageToJson(bad_request_detail))]
+ assert want_error_details == exception.details
+ # 404 POST comes from make_response.
+ assert str(exception) == (
+ "404 POST https://example.com/: 3 INVALID_ARGUMENT:"
+ " One of content, or gcs_content_uri must be set."
+ " [{'@type': 'type.googleapis.com/google.rpc.BadRequest',"
+ " 'fieldViolations': [{'field': 'document.content',"
+ " 'description': 'Must have some text content to annotate.'}]}]"
+ )
+
+
+def test_error_details_from_v1_rest_response():
+ response = make_response(
+ json.dumps(
+ {"error": {"message": "\u2019 message", "errors": ["1", "2"]}}
+ ).encode("utf-8")
+ )
+ exception = exceptions.from_http_response(response)
+ assert exception.details == []
+
+
+@pytest.mark.skipif(grpc is None, reason="gRPC not importable")
+def test_error_details_from_grpc_response():
+ status = rpc_status.status_pb2.Status()
+ status.code = 3
+ status.message = (
+ "3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
+ )
+ status_detail = create_bad_request_details()
+ status.details.append(status_detail)
+
+ # Actualy error doesn't matter as long as its grpc.Call,
+ # because from_call is mocked.
+ error = mock.create_autospec(grpc.Call, instance=True)
+ with mock.patch("grpc_status.rpc_status.from_call") as m:
+ m.return_value = status
+ exception = exceptions.from_grpc_error(error)
+
+ bad_request_detail = error_details_pb2.BadRequest()
+ status_detail.Unpack(bad_request_detail)
+ assert exception.details == [bad_request_detail]
+
+
+@pytest.mark.skipif(grpc is None, reason="gRPC not importable")
+def test_error_details_from_grpc_response_unknown_error():
+ status_detail = any_pb2.Any()
+
+ status = rpc_status.status_pb2.Status()
+ status.code = 3
+ status.message = (
+ "3 INVALID_ARGUMENT: One of content, or gcs_content_uri must be set."
+ )
+ status.details.append(status_detail)
+
+ error = mock.create_autospec(grpc.Call, instance=True)
+ with mock.patch("grpc_status.rpc_status.from_call") as m:
+ m.return_value = status
+ exception = exceptions.from_grpc_error(error)
+ assert exception.details == [status_detail]
diff --git a/tests/unit/test_grpc_helpers.py b/tests/unit/test_grpc_helpers.py
index 4c613a9..ca969e4 100644
--- a/tests/unit/test_grpc_helpers.py
+++ b/tests/unit/test_grpc_helpers.py
@@ -56,6 +56,9 @@ class RpcErrorImpl(grpc.RpcError, grpc.Call):
def details(self):
return None
+ def trailing_metadata(self):
+ return None
+
def test_wrap_unary_errors():
grpc_error = RpcErrorImpl(grpc.StatusCode.INVALID_ARGUMENT)