diff --git a/dapr/clients/exceptions.py b/dapr/clients/exceptions.py index e6afeaa07..9ea29dea9 100644 --- a/dapr/clients/exceptions.py +++ b/dapr/clients/exceptions.py @@ -14,7 +14,10 @@ """ import base64 import json -from typing import Optional +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from dapr.serializers import Serializer from google.protobuf.json_format import MessageToDict from grpc import RpcError # type: ignore @@ -56,6 +59,26 @@ def as_json_safe_dict(self): return error_dict + @property + def message(self) -> Optional[str]: + """Get the error message""" + return self._message + + @property + def error_code(self) -> Optional[str]: + """Get the error code""" + return self._error_code + + @property + def raw_response_bytes(self) -> Optional[bytes]: + """Get the raw response bytes""" + return self._raw_response_bytes + + def __str__(self): + if self._error_code != ERROR_CODE_UNKNOWN: + return f"('{self._message}', '{self._error_code}')" + return self._message or 'Unknown Dapr Error.' + class StatusDetails: def __init__(self): @@ -74,6 +97,66 @@ def as_dict(self): return {attr: getattr(self, attr) for attr in self.__dict__} +class DaprHttpError(DaprInternalError): + """DaprHttpError encapsulates all Dapr HTTP exceptions + + Attributes: + _status_code: HTTP status code + _reason: HTTP reason phrase + """ + + def __init__( + self, + serializer: 'Serializer', + raw_response_bytes: Optional[bytes] = None, + status_code: Optional[int] = None, + reason: Optional[str] = None, + ): + self._status_code = status_code + self._reason = reason + error_code: str = ERROR_CODE_UNKNOWN + message: Optional[str] = None + error_info: dict = None + + if (raw_response_bytes is None or len(raw_response_bytes) == 0) and status_code == 404: + error_code = ERROR_CODE_DOES_NOT_EXIST + raw_response_bytes = None + elif raw_response_bytes: + try: + error_info = serializer.deserialize(raw_response_bytes) + except Exception: + pass + # ignore any errors during deserialization + + if error_info and isinstance(error_info, dict): + message = error_info.get('message') + error_code = error_info.get('errorCode') or ERROR_CODE_UNKNOWN + + super().__init__( + message or f'HTTP status code: {status_code}', error_code, raw_response_bytes + ) + + @property + def status_code(self) -> Optional[int]: + return self._status_code + + @property + def reason(self) -> Optional[str]: + return self._reason + + def as_dict(self): + error_dict = super().as_dict() + error_dict['status_code'] = self._status_code + error_dict['reason'] = self._reason + return error_dict + + def __str__(self): + if self._error_code != ERROR_CODE_UNKNOWN: + return f'{self._message} (Error Code: {self._error_code}, Status Code: {self._status_code})' + else: + return f'Unknown Dapr Error. HTTP status code: {self._status_code}.' + + class DaprGrpcError(RpcError): def __init__(self, err: RpcError): self._status_code = err.code() diff --git a/dapr/clients/http/client.py b/dapr/clients/http/client.py index 6f2a8e3d9..5944e2782 100644 --- a/dapr/clients/http/client.py +++ b/dapr/clients/http/client.py @@ -31,7 +31,7 @@ from dapr.conf import settings from dapr.clients.base import DEFAULT_JSON_CONTENT_TYPE -from dapr.clients.exceptions import DaprInternalError, ERROR_CODE_DOES_NOT_EXIST, ERROR_CODE_UNKNOWN +from dapr.clients.exceptions import DaprHttpError, DaprInternalError class DaprHttpClient: @@ -102,26 +102,12 @@ async def send_bytes( raise (await self.convert_to_error(r)) async def convert_to_error(self, response: aiohttp.ClientResponse) -> DaprInternalError: - error_info = None - try: - error_body = await response.read() - if (error_body is None or len(error_body) == 0) and response.status == 404: - return DaprInternalError('Not Found', ERROR_CODE_DOES_NOT_EXIST) - error_info = self._serializer.deserialize(error_body) - except Exception: - return DaprInternalError( - f'Unknown Dapr Error. HTTP status code: {response.status}', - raw_response_bytes=error_body, - ) - - if error_info and isinstance(error_info, dict): - message = error_info.get('message') - error_code = error_info.get('errorCode') or ERROR_CODE_UNKNOWN - return DaprInternalError(message, error_code, raw_response_bytes=error_body) - - return DaprInternalError( - f'Unknown Dapr Error. HTTP status code: {response.status}', + error_body = await response.read() + return DaprHttpError( + self._serializer, raw_response_bytes=error_body, + status_code=response.status, + reason=response.reason, ) def get_ssl_context(self): diff --git a/examples/invoke-http/README.md b/examples/invoke-http/README.md index 08e8738e6..466392d20 100644 --- a/examples/invoke-http/README.md +++ b/examples/invoke-http/README.md @@ -51,6 +51,9 @@ expected_stdout_lines: - '== APP == 200' - '== APP == error occurred' - '== APP == MY_CODE' + - '== APP == {"message": "error occurred", "errorCode": "MY_CODE"}' + - '== APP == 503' + - '== APP == Internal Server Error' background: true sleep: 5 --> diff --git a/examples/invoke-http/invoke-caller.py b/examples/invoke-http/invoke-caller.py index ebc5876b9..380001592 100644 --- a/examples/invoke-http/invoke-caller.py +++ b/examples/invoke-http/invoke-caller.py @@ -2,6 +2,7 @@ import time from dapr.clients import DaprClient +from dapr.clients.exceptions import DaprHttpError with DaprClient() as d: req_data = {'id': 1, 'message': 'hello world'} @@ -29,6 +30,9 @@ http_verb='POST', data=json.dumps(req_data), ) - except Exception as e: - print(e._message, flush=True) - print(e._error_code, flush=True) + except DaprHttpError as e: + print(e.message, flush=True) + print(e.error_code, flush=True) + print(e.raw_response_bytes, flush=True) + print(str(e.status_code), flush=True) + print(e.reason, flush=True) diff --git a/tests/clients/test_http_service_invocation_client.py b/tests/clients/test_http_service_invocation_client.py index d45c530ba..c0b43a863 100644 --- a/tests/clients/test_http_service_invocation_client.py +++ b/tests/clients/test_http_service_invocation_client.py @@ -205,10 +205,10 @@ def test_invoke_method_protobuf_response_case_insensitive(self): self.assertEqual('resp', new_resp.key) def test_invoke_method_error_returned(self): - error_response = b'{"errorCode":"ERR_DIRECT_INVOKE","message":"Something bad happend"}' + error_response = b'{"errorCode":"ERR_DIRECT_INVOKE","message":"Something bad happened"}' self.server.set_response(error_response, 500) - expected_msg = "('Something bad happend', 'ERR_DIRECT_INVOKE')" + expected_msg = 'Something bad happened (Error Code: ERR_DIRECT_INVOKE, Status Code: 500)' with self.assertRaises(DaprInternalError) as ctx: self.client.invoke_method( @@ -223,7 +223,7 @@ def test_invoke_method_non_dapr_error(self): error_response = b'UNPARSABLE_ERROR' self.server.set_response(error_response, 500) - expected_msg = 'Unknown Dapr Error. HTTP status code: 500' + expected_msg = 'Unknown Dapr Error. HTTP status code: 500.' with self.assertRaises(DaprInternalError) as ctx: self.client.invoke_method( diff --git a/tests/clients/test_secure_http_service_invocation_client.py b/tests/clients/test_secure_http_service_invocation_client.py index f23bc11c1..4d1bdda1f 100644 --- a/tests/clients/test_secure_http_service_invocation_client.py +++ b/tests/clients/test_secure_http_service_invocation_client.py @@ -23,6 +23,7 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from dapr.clients import DaprClient, DaprGrpcClient +from dapr.clients.exceptions import DaprInternalError from dapr.clients.health import DaprHealth from dapr.clients.http.client import DaprHttpClient from dapr.conf import settings @@ -139,3 +140,68 @@ def test_timeout_exception_thrown_when_timeout_reached(self): self.server.set_server_delay(1.5) with self.assertRaises(TimeoutError): new_client.invoke_method(self.app_id, self.method_name, '') + + def test_notfound_json_body_exception_thrown_with_status_code_and_reason(self): + self.server.set_response(b'{"error": "Not found"}', code=404) + with self.assertRaises(DaprInternalError) as context: + self.client.invoke_method(self.app_id, self.method_name, '') + + error_dict = context.exception.as_dict() + self.assertEqual('HTTP status code: 404', error_dict.get('message')) + self.assertEqual('UNKNOWN', error_dict.get('errorCode')) + self.assertEqual(b'{"error": "Not found"}', error_dict.get('raw_response_bytes')) + self.assertEqual(404, error_dict.get('status_code')) + self.assertEqual('Not Found', error_dict.get('reason')) + + def test_notfound_no_body_exception_thrown_with_status_code_and_reason(self): + self.server.set_response(b'', code=404) + with self.assertRaises(DaprInternalError) as context: + self.client.invoke_method(self.app_id, self.method_name, '') + + error_dict = context.exception.as_dict() + self.assertEqual('HTTP status code: 404', error_dict.get('message')) + self.assertEqual('ERR_DOES_NOT_EXIST', error_dict.get('errorCode')) + self.assertEqual(None, error_dict.get('raw_response_bytes')) + self.assertEqual(404, error_dict.get('status_code')) + self.assertEqual('Not Found', error_dict.get('reason')) + + def test_internal_error_no_body_exception_thrown_with_status_code_and_reason(self): + self.server.set_response(b'', code=500) + with self.assertRaises(DaprInternalError) as context: + self.client.invoke_method(self.app_id, self.method_name, '') + + error_dict = context.exception.as_dict() + self.assertEqual('HTTP status code: 500', error_dict.get('message')) + self.assertEqual('UNKNOWN', error_dict.get('errorCode')) + self.assertEqual(b'', error_dict.get('raw_response_bytes')) + self.assertEqual(500, error_dict.get('status_code')) + self.assertEqual('Internal Server Error', error_dict.get('reason')) + + def test_notfound_no_json_body_exception_thrown_with_status_code_and_reason(self): + self.server.set_response(b'Not found', code=404) + with self.assertRaises(DaprInternalError) as context: + self.client.invoke_method(self.app_id, self.method_name, '') + + error_dict = context.exception.as_dict() + self.assertEqual('HTTP status code: 404', error_dict.get('message')) + self.assertEqual('UNKNOWN', error_dict.get('errorCode')) + self.assertEqual(b'Not found', error_dict.get('raw_response_bytes')) + self.assertEqual(404, error_dict.get('status_code')) + self.assertEqual('Not Found', error_dict.get('reason')) + + def test_notfound_json_body_w_message_exception_thrown_with_status_code_and_reason(self): + self.server.set_response( + b'{"message": "My message", "errorCode": "MY_ERROR_CODE"}', code=404 + ) + with self.assertRaises(DaprInternalError) as context: + self.client.invoke_method(self.app_id, self.method_name, '') + + error_dict = context.exception.as_dict() + self.assertEqual('My message', error_dict.get('message')) + self.assertEqual('MY_ERROR_CODE', error_dict.get('errorCode')) + self.assertEqual( + b'{"message": "My message", "errorCode": "MY_ERROR_CODE"}', + error_dict.get('raw_response_bytes'), + ) + self.assertEqual(404, error_dict.get('status_code')) + self.assertEqual('Not Found', error_dict.get('reason'))