From b3843adb5133540a709813e7d785c38cda94c8d2 Mon Sep 17 00:00:00 2001 From: Renat Akzholov <31467120+rrrrs09@users.noreply.github.com> Date: Mon, 19 Jul 2021 18:54:12 +0300 Subject: [PATCH] Feature/handle request errors (#150) * feat: handle response body and connect errors * test: botx method with empty error handlers * docs: add release changes --- botx/clients/clients/async_client.py | 39 ++++++++++---- botx/clients/clients/sync_client.py | 39 ++++++++++---- botx/clients/methods/errors/bot_not_found.py | 32 ++++++++++++ .../methods/errors/unauthorized_bot.py | 34 +++++++++++++ botx/clients/methods/v2/bots/token.py | 6 +++ botx/exceptions.py | 27 ++++++++++ docs/changelog.md | 10 ++++ setup.cfg | 5 ++ .../test_async_client/__init__.py | 0 .../test_async_client/test_execute.py | 51 +++++++++++++++++++ .../test_sync_client/test_execute.py | 42 +++++++++++++-- .../test_base/test_empty_error_handlers.py | 13 +++++ .../test_errors/test_bot_not_found.py | 28 ++++++++++ .../test_errors/test_unauthorized_bot.py | 28 ++++++++++ 14 files changed, 333 insertions(+), 21 deletions(-) create mode 100644 botx/clients/methods/errors/bot_not_found.py create mode 100644 botx/clients/methods/errors/unauthorized_bot.py create mode 100644 tests/test_clients/test_clients/test_async_client/__init__.py create mode 100644 tests/test_clients/test_clients/test_async_client/test_execute.py create mode 100644 tests/test_clients/test_methods/test_base/test_empty_error_handlers.py create mode 100644 tests/test_clients/test_methods/test_errors/test_bot_not_found.py create mode 100644 tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py diff --git a/botx/clients/clients/async_client.py b/botx/clients/clients/async_client.py index d98ed662..611d680b 100644 --- a/botx/clients/clients/async_client.py +++ b/botx/clients/clients/async_client.py @@ -1,6 +1,7 @@ """Definition for async client for BotX API.""" from dataclasses import field from http import HTTPStatus +from json import JSONDecodeError from typing import Any, List, TypeVar import httpx @@ -10,7 +11,12 @@ from botx.clients.methods.base import BotXMethod from botx.clients.types.http import HTTPRequest, HTTPResponse from botx.converters import optional_sequence_to_list -from botx.exceptions import BotXAPIError, BotXAPIRouteDeprecated +from botx.exceptions import ( + BotXAPIError, + BotXAPIRouteDeprecated, + BotXConnectError, + BotXJSONDecodeError, +) from botx.shared import BotXDataclassConfig ResponseT = TypeVar("ResponseT") @@ -90,16 +96,31 @@ async def execute(self, request: HTTPRequest) -> HTTPResponse: Returns: HTTP response from API. + + Raises: + BotXConnectError: raised if unable to connect to service. + BotXJSONDecodeError: raised if service returned invalid body. """ - response = await self.http_client.request( - request.method, - request.url, - headers=request.headers, - params=request.query_params, - json=request.json_body, - ) + try: + response = await self.http_client.request( + request.method, + request.url, + headers=request.headers, + params=request.query_params, + json=request.json_body, + ) + except httpx.HTTPError as httpx_exc: + raise BotXConnectError( + url=request.url, + method=request.method, + ) from httpx_exc + + try: + json_body = response.json() + except JSONDecodeError as exc: + raise BotXJSONDecodeError(url=request.url, method=request.method) from exc return HTTPResponse( status_code=response.status_code, - json_body=response.json(), + json_body=json_body, ) diff --git a/botx/clients/clients/sync_client.py b/botx/clients/clients/sync_client.py index 3bbaa978..02601aab 100644 --- a/botx/clients/clients/sync_client.py +++ b/botx/clients/clients/sync_client.py @@ -1,6 +1,7 @@ """Definition for sync client for BotX API.""" from dataclasses import field from http import HTTPStatus +from json import JSONDecodeError from typing import Any, List, TypeVar import httpx @@ -11,7 +12,12 @@ from botx.clients.methods.base import BotXMethod, ErrorHandlersInMethod from botx.clients.types.http import HTTPRequest, HTTPResponse from botx.converters import optional_sequence_to_list -from botx.exceptions import BotXAPIError, BotXAPIRouteDeprecated +from botx.exceptions import ( + BotXAPIError, + BotXAPIRouteDeprecated, + BotXConnectError, + BotXJSONDecodeError, +) from botx.shared import BotXDataclassConfig ResponseT = TypeVar("ResponseT") @@ -90,18 +96,33 @@ def execute(self, request: HTTPRequest) -> HTTPResponse: Returns: HTTP response from API. + + Raises: + BotXConnectError: raised if unable to connect to service. + BotXJSONDecodeError: raised if service returned invalid body. """ - response = self.http_client.request( - request.method, - request.url, - headers=request.headers, - params=request.query_params, - json=request.json_body, - ) + try: + response = self.http_client.request( + request.method, + request.url, + headers=request.headers, + params=request.query_params, + json=request.json_body, + ) + except httpx.HTTPError as httpx_exc: + raise BotXConnectError( + url=request.url, + method=request.method, + ) from httpx_exc + + try: + json_body = response.json() + except JSONDecodeError as exc: + raise BotXJSONDecodeError(url=request.url, method=request.method) from exc return HTTPResponse( status_code=response.status_code, - json_body=response.json(), + json_body=json_body, ) diff --git a/botx/clients/methods/errors/bot_not_found.py b/botx/clients/methods/errors/bot_not_found.py new file mode 100644 index 00000000..ab08af3b --- /dev/null +++ b/botx/clients/methods/errors/bot_not_found.py @@ -0,0 +1,32 @@ +"""Definition for "bot not found" error.""" +from typing import NoReturn + +from botx.clients.methods.base import APIErrorResponse, BotXMethod +from botx.clients.types.http import HTTPResponse +from botx.exceptions import BotXAPIError + + +class BotNotFoundError(BotXAPIError): + """Error for raising when bot not found.""" + + message_template = "bot with id `{bot_id}` not found. " + + +def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: + """Handle "bot not found" error response. + + Arguments: + method: method which was made before error. + response: HTTP response from BotX API. + + Raises: + BotNotFoundError: raised always. + """ + APIErrorResponse[dict].parse_obj(response.json_body) + raise BotNotFoundError( + url=method.url, + method=method.http_method, + response_content=response.json_body, + status_content=response.status_code, + bot_id=method.bot_id, # type: ignore + ) diff --git a/botx/clients/methods/errors/unauthorized_bot.py b/botx/clients/methods/errors/unauthorized_bot.py new file mode 100644 index 00000000..a956dee6 --- /dev/null +++ b/botx/clients/methods/errors/unauthorized_bot.py @@ -0,0 +1,34 @@ +"""Definition for "invalid bot credentials" error.""" +from typing import NoReturn + +from botx.clients.methods.base import APIErrorResponse, BotXMethod +from botx.clients.types.http import HTTPResponse +from botx.exceptions import BotXAPIError + + +class InvalidBotCredentials(BotXAPIError): + """Error for raising when got invalid bot credentials.""" + + message_template = ( + "Can't get token for bot {bot_id}. Make sure bot credentials is correct" + ) + + +def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: + """Handle "invalid bot credentials" error response. + + Arguments: + method: method which was made before error. + response: HTTP response from BotX API. + + Raises: + InvalidBotCredentials: raised always. + """ + APIErrorResponse[dict].parse_obj(response.json_body) + raise InvalidBotCredentials( + url=method.url, + method=method.http_method, + response_content=response.json_body, + status_content=response.status_code, + bot_id=method.bot_id, # type: ignore + ) diff --git a/botx/clients/methods/v2/bots/token.py b/botx/clients/methods/v2/bots/token.py index 9e851c56..624411e8 100644 --- a/botx/clients/methods/v2/bots/token.py +++ b/botx/clients/methods/v2/bots/token.py @@ -1,9 +1,11 @@ """Method for retrieving token for bot.""" +from http import HTTPStatus from typing import Dict from urllib.parse import urljoin from uuid import UUID from botx.clients.methods.base import BotXMethod, PrimitiveDataType +from botx.clients.methods.errors import bot_not_found, unauthorized_bot class Token(BotXMethod[str]): @@ -12,6 +14,10 @@ class Token(BotXMethod[str]): __url__ = "/api/v2/botx/bots/{bot_id}/token" __method__ = "GET" __returning__ = str + __errors_handlers__ = { + HTTPStatus.NOT_FOUND: bot_not_found.handle_error, + HTTPStatus.UNAUTHORIZED: unauthorized_bot.handle_error, + } #: ID of bot which access for token. bot_id: UUID diff --git a/botx/exceptions.py b/botx/exceptions.py index 29d5bb2a..de36642b 100644 --- a/botx/exceptions.py +++ b/botx/exceptions.py @@ -83,3 +83,30 @@ class TokenError(BotXException): message_template = "invalid token for bot {bot_id}" bot_id: UUID + + +class BotXJSONDecodeError(BotXException): + """Raised if response body cannot be processed.""" + + message_template = "unable to process response body from {method} {url}" + + #: URL from request. + url: str + + #: HTTP method. + method: str + + +class BotXConnectError(BotXException): + """Raised if unable to connect to service.""" + + message_template = ( + "unable to connect to service {method} {url}. " + "Make sure you specified the correct host in bot credentials." + ) + + #: URL from request. + url: str + + #: HTTP method. + method: str diff --git a/docs/changelog.md b/docs/changelog.md index 69c23815..489e781b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,13 @@ +## 0.20.1 (Jul 19, 2021) + +### Added + +* Add `bot not found` error handler for `Token` method. +* Add `invalid bot credentials` error handler for `Token` method. +* Add `connection error` handler for all BotX methods. +* Add `JSON decoding error` handler for all BotX methods. + + ## 0.20.0 (Jul 08, 2021) Tested on BotX 1.42.0-rc4 diff --git a/setup.cfg b/setup.cfg index 25cb2b8b..ad815cef 100644 --- a/setup.cfg +++ b/setup.cfg @@ -168,6 +168,9 @@ per-file-ignores = # many chat methods botx/bots/mixins/requests/chats.py: WPS201, WPS214 + # too many module members + botx/exceptions.py: WPS202 + # Disable some checks: ignore = # Docs: @@ -180,6 +183,8 @@ ignore = # 3xx # Disable required inheritance from object: WPS306, + # Allow implicit string concatenation + WPS326, # 6xx # A lot of functionality in this lib is build around async __call__: diff --git a/tests/test_clients/test_clients/test_async_client/__init__.py b/tests/test_clients/test_clients/test_async_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_clients/test_clients/test_async_client/test_execute.py b/tests/test_clients/test_clients/test_async_client/test_execute.py new file mode 100644 index 00000000..cd0a3281 --- /dev/null +++ b/tests/test_clients/test_clients/test_async_client/test_execute.py @@ -0,0 +1,51 @@ +import uuid + +import pytest +from httpx import ConnectError, Request, Response + +from botx.clients.methods.v2.bots.token import Token +from botx.exceptions import BotXConnectError, BotXJSONDecodeError + +try: + from unittest.mock import AsyncMock +except ImportError: + from unittest.mock import MagicMock + + # Used for compatibility with python 3.7 + class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +@pytest.fixture() +def token_method(): + return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature") + + +@pytest.fixture() +def mock_http_client(): + return AsyncMock() + + +@pytest.mark.asyncio() +async def test_raising_connection_error(client, token_method, mock_http_client): + request = Request(token_method.http_method, token_method.url) + mock_http_client.request.side_effect = ConnectError("Test error", request=request) + + client.bot.client.http_client = mock_http_client + botx_request = client.bot.client.build_request(token_method) + + with pytest.raises(BotXConnectError): + await client.bot.client.execute(botx_request) + + +@pytest.mark.asyncio() +async def test_raising_decode_error(client, token_method, mock_http_client): + response = Response(status_code=418, text="Wrong json") + mock_http_client.request.return_value = response + + client.bot.client.http_client = mock_http_client + botx_request = client.bot.client.build_request(token_method) + + with pytest.raises(BotXJSONDecodeError): + await client.bot.client.execute(botx_request) diff --git a/tests/test_clients/test_clients/test_sync_client/test_execute.py b/tests/test_clients/test_clients/test_sync_client/test_execute.py index c7488fc4..5e90e9ef 100644 --- a/tests/test_clients/test_clients/test_sync_client/test_execute.py +++ b/tests/test_clients/test_clients/test_sync_client/test_execute.py @@ -1,10 +1,46 @@ import uuid +from unittest.mock import Mock + +import pytest +from httpx import ConnectError, Request, Response from botx.clients.methods.v2.bots.token import Token +from botx.exceptions import BotXConnectError, BotXJSONDecodeError + + +@pytest.fixture() +def token_method(): + return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature") + +@pytest.fixture() +def mock_http_client(): + return Mock() -def test_execute_without_explicit_host(client): - method = Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature") - request = client.bot.sync_client.build_request(method) + +def test_execute_without_explicit_host(client, token_method): + request = client.bot.sync_client.build_request(token_method) assert client.bot.sync_client.execute(request) + + +def test_raising_connection_error(client, token_method, mock_http_client): + request = Request(token_method.http_method, token_method.url) + mock_http_client.request.side_effect = ConnectError("Test error", request=request) + + client.bot.sync_client.http_client = mock_http_client + botx_request = client.bot.sync_client.build_request(token_method) + + with pytest.raises(BotXConnectError): + client.bot.sync_client.execute(botx_request) + + +def test_raising_decode_error(client, token_method, mock_http_client): + response = Response(status_code=418, text="Wrong json") + mock_http_client.request.return_value = response + + client.bot.sync_client.http_client = mock_http_client + botx_request = client.bot.sync_client.build_request(token_method) + + with pytest.raises(BotXJSONDecodeError): + client.bot.sync_client.execute(botx_request) diff --git a/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py b/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py new file mode 100644 index 00000000..89d3b0c5 --- /dev/null +++ b/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py @@ -0,0 +1,13 @@ +from botx.clients.methods.base import BotXMethod + + +class TestMethod(BotXMethod): + __url__ = "/path/to/example" + __method__ = "GET" + __returning__ = str + + +def test_method_empty_error_handlers(): + test_method = TestMethod() + + assert test_method.__errors_handlers__ == {} diff --git a/tests/test_clients/test_methods/test_errors/test_bot_not_found.py b/tests/test_clients/test_methods/test_errors/test_bot_not_found.py new file mode 100644 index 00000000..1ae420f1 --- /dev/null +++ b/tests/test_clients/test_methods/test_errors/test_bot_not_found.py @@ -0,0 +1,28 @@ +import uuid +from http import HTTPStatus + +import pytest + +from botx.clients.methods.errors.bot_not_found import BotNotFoundError +from botx.clients.methods.v2.bots.token import Token +from botx.concurrency import callable_to_coroutine + +pytestmark = pytest.mark.asyncio +pytest_plugins = ("tests.test_clients.fixtures",) + + +async def test_raising_bot_not_found_error(client, requests_client): + method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature") + + errors_to_raise = {Token: (HTTPStatus.NOT_FOUND, {})} + + with client.error_client(errors=errors_to_raise): + request = requests_client.build_request(method) + response = await callable_to_coroutine(requests_client.execute, request) + + with pytest.raises(BotNotFoundError): + await callable_to_coroutine( + requests_client.process_response, + method, + response, + ) diff --git a/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py b/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py new file mode 100644 index 00000000..27105221 --- /dev/null +++ b/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py @@ -0,0 +1,28 @@ +import uuid +from http import HTTPStatus + +import pytest + +from botx.clients.methods.errors.unauthorized_bot import InvalidBotCredentials +from botx.clients.methods.v2.bots.token import Token +from botx.concurrency import callable_to_coroutine + +pytestmark = pytest.mark.asyncio +pytest_plugins = ("tests.test_clients.fixtures",) + + +async def test_raising_unauthorized_bot_error(client, requests_client): + method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature") + + errors_to_raise = {Token: (HTTPStatus.UNAUTHORIZED, {})} + + with client.error_client(errors=errors_to_raise): + request = requests_client.build_request(method) + response = await callable_to_coroutine(requests_client.execute, request) + + with pytest.raises(InvalidBotCredentials): + await callable_to_coroutine( + requests_client.process_response, + method, + response, + )