Skip to content

Improve the error handling for HTTP client so consumers can trigger appropriate behavior #793

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion dapr/clients/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Check warning on line 20 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L20

Added line #L20 was not covered by tests

from google.protobuf.json_format import MessageToDict
from grpc import RpcError # type: ignore
Expand Down Expand Up @@ -56,6 +59,26 @@

return error_dict

@property
def message(self) -> Optional[str]:
"""Get the error message"""
return self._message

Check warning on line 65 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L65

Added line #L65 was not covered by tests

@property
def error_code(self) -> Optional[str]:
"""Get the error code"""
return self._error_code

Check warning on line 70 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L70

Added line #L70 was not covered by tests

@property
def raw_response_bytes(self) -> Optional[bytes]:
"""Get the raw response bytes"""
return self._raw_response_bytes

Check warning on line 75 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L75

Added line #L75 was not covered by tests

def __str__(self):
if self._error_code != ERROR_CODE_UNKNOWN:
return f"('{self._message}', '{self._error_code}')"

Check warning on line 79 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L79

Added line #L79 was not covered by tests
return self._message or 'Unknown Dapr Error.'


class StatusDetails:
def __init__(self):
Expand All @@ -74,6 +97,66 @@
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: Optional[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

Check warning on line 141 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L141

Added line #L141 was not covered by tests

@property
def reason(self) -> Optional[str]:
return self._reason

Check warning on line 145 in dapr/clients/exceptions.py

View check run for this annotation

Codecov / codecov/patch

dapr/clients/exceptions.py#L145

Added line #L145 was not covered by tests

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()
Expand Down
26 changes: 6 additions & 20 deletions dapr/clients/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions examples/invoke-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-->
Expand Down
10 changes: 7 additions & 3 deletions examples/invoke-http/invoke-caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down Expand Up @@ -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)
1 change: 0 additions & 1 deletion ext/dapr-ext-workflow/tests/test_workflow_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def schedule_new_orchestration(
return mock_schedule_result

def get_orchestration_state(self, instance_id, fetch_payloads):
global wf_status
if wf_status == 'not-found':
raise SimulatedRpcError(code='UNKNOWN', details='no such instance exists')
elif wf_status == 'found':
Expand Down
6 changes: 3 additions & 3 deletions tests/clients/test_http_service_invocation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
66 changes: 66 additions & 0 deletions tests/clients/test_secure_http_service_invocation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'))