Skip to content

Commit

Permalink
Feature/handle request errors (#150)
Browse files Browse the repository at this point in the history
* feat: handle response body and connect errors

* test: botx method with empty error handlers

* docs: add release changes
  • Loading branch information
rrrrs09 committed Jul 19, 2021
1 parent 7a8a42a commit b3843ad
Show file tree
Hide file tree
Showing 14 changed files with 333 additions and 21 deletions.
39 changes: 30 additions & 9 deletions botx/clients/clients/async_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
)
39 changes: 30 additions & 9 deletions botx/clients/clients/sync_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
)


Expand Down
32 changes: 32 additions & 0 deletions botx/clients/methods/errors/bot_not_found.py
Original file line number Diff line number Diff line change
@@ -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
)
34 changes: 34 additions & 0 deletions botx/clients/methods/errors/unauthorized_bot.py
Original file line number Diff line number Diff line change
@@ -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
)
6 changes: 6 additions & 0 deletions botx/clients/methods/v2/bots/token.py
Original file line number Diff line number Diff line change
@@ -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]):
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions botx/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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__:
Expand Down
Empty file.
51 changes: 51 additions & 0 deletions tests/test_clients/test_clients/test_async_client/test_execute.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 39 additions & 3 deletions tests/test_clients/test_clients/test_sync_client/test_execute.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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__ == {}
Loading

1 comment on commit b3843ad

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 Published on https://pybotx.netlify.app as production
🚀 Deployed on https://60f5a0a1bf06fe3cf7356832--pybotx.netlify.app

Please sign in to comment.