Skip to content

Commit b3843ad

Browse files
authored
Feature/handle request errors (#150)
* feat: handle response body and connect errors * test: botx method with empty error handlers * docs: add release changes
1 parent 7a8a42a commit b3843ad

File tree

14 files changed

+333
-21
lines changed

14 files changed

+333
-21
lines changed

botx/clients/clients/async_client.py

+30-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Definition for async client for BotX API."""
22
from dataclasses import field
33
from http import HTTPStatus
4+
from json import JSONDecodeError
45
from typing import Any, List, TypeVar
56

67
import httpx
@@ -10,7 +11,12 @@
1011
from botx.clients.methods.base import BotXMethod
1112
from botx.clients.types.http import HTTPRequest, HTTPResponse
1213
from botx.converters import optional_sequence_to_list
13-
from botx.exceptions import BotXAPIError, BotXAPIRouteDeprecated
14+
from botx.exceptions import (
15+
BotXAPIError,
16+
BotXAPIRouteDeprecated,
17+
BotXConnectError,
18+
BotXJSONDecodeError,
19+
)
1420
from botx.shared import BotXDataclassConfig
1521

1622
ResponseT = TypeVar("ResponseT")
@@ -90,16 +96,31 @@ async def execute(self, request: HTTPRequest) -> HTTPResponse:
9096
9197
Returns:
9298
HTTP response from API.
99+
100+
Raises:
101+
BotXConnectError: raised if unable to connect to service.
102+
BotXJSONDecodeError: raised if service returned invalid body.
93103
"""
94-
response = await self.http_client.request(
95-
request.method,
96-
request.url,
97-
headers=request.headers,
98-
params=request.query_params,
99-
json=request.json_body,
100-
)
104+
try:
105+
response = await self.http_client.request(
106+
request.method,
107+
request.url,
108+
headers=request.headers,
109+
params=request.query_params,
110+
json=request.json_body,
111+
)
112+
except httpx.HTTPError as httpx_exc:
113+
raise BotXConnectError(
114+
url=request.url,
115+
method=request.method,
116+
) from httpx_exc
117+
118+
try:
119+
json_body = response.json()
120+
except JSONDecodeError as exc:
121+
raise BotXJSONDecodeError(url=request.url, method=request.method) from exc
101122

102123
return HTTPResponse(
103124
status_code=response.status_code,
104-
json_body=response.json(),
125+
json_body=json_body,
105126
)

botx/clients/clients/sync_client.py

+30-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Definition for sync client for BotX API."""
22
from dataclasses import field
33
from http import HTTPStatus
4+
from json import JSONDecodeError
45
from typing import Any, List, TypeVar
56

67
import httpx
@@ -11,7 +12,12 @@
1112
from botx.clients.methods.base import BotXMethod, ErrorHandlersInMethod
1213
from botx.clients.types.http import HTTPRequest, HTTPResponse
1314
from botx.converters import optional_sequence_to_list
14-
from botx.exceptions import BotXAPIError, BotXAPIRouteDeprecated
15+
from botx.exceptions import (
16+
BotXAPIError,
17+
BotXAPIRouteDeprecated,
18+
BotXConnectError,
19+
BotXJSONDecodeError,
20+
)
1521
from botx.shared import BotXDataclassConfig
1622

1723
ResponseT = TypeVar("ResponseT")
@@ -90,18 +96,33 @@ def execute(self, request: HTTPRequest) -> HTTPResponse:
9096
9197
Returns:
9298
HTTP response from API.
99+
100+
Raises:
101+
BotXConnectError: raised if unable to connect to service.
102+
BotXJSONDecodeError: raised if service returned invalid body.
93103
"""
94-
response = self.http_client.request(
95-
request.method,
96-
request.url,
97-
headers=request.headers,
98-
params=request.query_params,
99-
json=request.json_body,
100-
)
104+
try:
105+
response = self.http_client.request(
106+
request.method,
107+
request.url,
108+
headers=request.headers,
109+
params=request.query_params,
110+
json=request.json_body,
111+
)
112+
except httpx.HTTPError as httpx_exc:
113+
raise BotXConnectError(
114+
url=request.url,
115+
method=request.method,
116+
) from httpx_exc
117+
118+
try:
119+
json_body = response.json()
120+
except JSONDecodeError as exc:
121+
raise BotXJSONDecodeError(url=request.url, method=request.method) from exc
101122

102123
return HTTPResponse(
103124
status_code=response.status_code,
104-
json_body=response.json(),
125+
json_body=json_body,
105126
)
106127

107128

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Definition for "bot not found" error."""
2+
from typing import NoReturn
3+
4+
from botx.clients.methods.base import APIErrorResponse, BotXMethod
5+
from botx.clients.types.http import HTTPResponse
6+
from botx.exceptions import BotXAPIError
7+
8+
9+
class BotNotFoundError(BotXAPIError):
10+
"""Error for raising when bot not found."""
11+
12+
message_template = "bot with id `{bot_id}` not found. "
13+
14+
15+
def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
16+
"""Handle "bot not found" error response.
17+
18+
Arguments:
19+
method: method which was made before error.
20+
response: HTTP response from BotX API.
21+
22+
Raises:
23+
BotNotFoundError: raised always.
24+
"""
25+
APIErrorResponse[dict].parse_obj(response.json_body)
26+
raise BotNotFoundError(
27+
url=method.url,
28+
method=method.http_method,
29+
response_content=response.json_body,
30+
status_content=response.status_code,
31+
bot_id=method.bot_id, # type: ignore
32+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Definition for "invalid bot credentials" error."""
2+
from typing import NoReturn
3+
4+
from botx.clients.methods.base import APIErrorResponse, BotXMethod
5+
from botx.clients.types.http import HTTPResponse
6+
from botx.exceptions import BotXAPIError
7+
8+
9+
class InvalidBotCredentials(BotXAPIError):
10+
"""Error for raising when got invalid bot credentials."""
11+
12+
message_template = (
13+
"Can't get token for bot {bot_id}. Make sure bot credentials is correct"
14+
)
15+
16+
17+
def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn:
18+
"""Handle "invalid bot credentials" error response.
19+
20+
Arguments:
21+
method: method which was made before error.
22+
response: HTTP response from BotX API.
23+
24+
Raises:
25+
InvalidBotCredentials: raised always.
26+
"""
27+
APIErrorResponse[dict].parse_obj(response.json_body)
28+
raise InvalidBotCredentials(
29+
url=method.url,
30+
method=method.http_method,
31+
response_content=response.json_body,
32+
status_content=response.status_code,
33+
bot_id=method.bot_id, # type: ignore
34+
)

botx/clients/methods/v2/bots/token.py

+6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Method for retrieving token for bot."""
2+
from http import HTTPStatus
23
from typing import Dict
34
from urllib.parse import urljoin
45
from uuid import UUID
56

67
from botx.clients.methods.base import BotXMethod, PrimitiveDataType
8+
from botx.clients.methods.errors import bot_not_found, unauthorized_bot
79

810

911
class Token(BotXMethod[str]):
@@ -12,6 +14,10 @@ class Token(BotXMethod[str]):
1214
__url__ = "/api/v2/botx/bots/{bot_id}/token"
1315
__method__ = "GET"
1416
__returning__ = str
17+
__errors_handlers__ = {
18+
HTTPStatus.NOT_FOUND: bot_not_found.handle_error,
19+
HTTPStatus.UNAUTHORIZED: unauthorized_bot.handle_error,
20+
}
1521

1622
#: ID of bot which access for token.
1723
bot_id: UUID

botx/exceptions.py

+27
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,30 @@ class TokenError(BotXException):
8383
message_template = "invalid token for bot {bot_id}"
8484

8585
bot_id: UUID
86+
87+
88+
class BotXJSONDecodeError(BotXException):
89+
"""Raised if response body cannot be processed."""
90+
91+
message_template = "unable to process response body from {method} {url}"
92+
93+
#: URL from request.
94+
url: str
95+
96+
#: HTTP method.
97+
method: str
98+
99+
100+
class BotXConnectError(BotXException):
101+
"""Raised if unable to connect to service."""
102+
103+
message_template = (
104+
"unable to connect to service {method} {url}. "
105+
"Make sure you specified the correct host in bot credentials."
106+
)
107+
108+
#: URL from request.
109+
url: str
110+
111+
#: HTTP method.
112+
method: str

docs/changelog.md

+10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 0.20.1 (Jul 19, 2021)
2+
3+
### Added
4+
5+
* Add `bot not found` error handler for `Token` method.
6+
* Add `invalid bot credentials` error handler for `Token` method.
7+
* Add `connection error` handler for all BotX methods.
8+
* Add `JSON decoding error` handler for all BotX methods.
9+
10+
111
## 0.20.0 (Jul 08, 2021)
212

313
Tested on BotX 1.42.0-rc4

setup.cfg

+5
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ per-file-ignores =
168168
# many chat methods
169169
botx/bots/mixins/requests/chats.py: WPS201, WPS214
170170

171+
# too many module members
172+
botx/exceptions.py: WPS202
173+
171174
# Disable some checks:
172175
ignore =
173176
# Docs:
@@ -180,6 +183,8 @@ ignore =
180183
# 3xx
181184
# Disable required inheritance from object:
182185
WPS306,
186+
# Allow implicit string concatenation
187+
WPS326,
183188

184189
# 6xx
185190
# A lot of functionality in this lib is build around async __call__:

tests/test_clients/test_clients/test_async_client/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import uuid
2+
3+
import pytest
4+
from httpx import ConnectError, Request, Response
5+
6+
from botx.clients.methods.v2.bots.token import Token
7+
from botx.exceptions import BotXConnectError, BotXJSONDecodeError
8+
9+
try:
10+
from unittest.mock import AsyncMock
11+
except ImportError:
12+
from unittest.mock import MagicMock
13+
14+
# Used for compatibility with python 3.7
15+
class AsyncMock(MagicMock):
16+
async def __call__(self, *args, **kwargs):
17+
return super(AsyncMock, self).__call__(*args, **kwargs)
18+
19+
20+
@pytest.fixture()
21+
def token_method():
22+
return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature")
23+
24+
25+
@pytest.fixture()
26+
def mock_http_client():
27+
return AsyncMock()
28+
29+
30+
@pytest.mark.asyncio()
31+
async def test_raising_connection_error(client, token_method, mock_http_client):
32+
request = Request(token_method.http_method, token_method.url)
33+
mock_http_client.request.side_effect = ConnectError("Test error", request=request)
34+
35+
client.bot.client.http_client = mock_http_client
36+
botx_request = client.bot.client.build_request(token_method)
37+
38+
with pytest.raises(BotXConnectError):
39+
await client.bot.client.execute(botx_request)
40+
41+
42+
@pytest.mark.asyncio()
43+
async def test_raising_decode_error(client, token_method, mock_http_client):
44+
response = Response(status_code=418, text="Wrong json")
45+
mock_http_client.request.return_value = response
46+
47+
client.bot.client.http_client = mock_http_client
48+
botx_request = client.bot.client.build_request(token_method)
49+
50+
with pytest.raises(BotXJSONDecodeError):
51+
await client.bot.client.execute(botx_request)
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,46 @@
11
import uuid
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
from httpx import ConnectError, Request, Response
26

37
from botx.clients.methods.v2.bots.token import Token
8+
from botx.exceptions import BotXConnectError, BotXJSONDecodeError
9+
10+
11+
@pytest.fixture()
12+
def token_method():
13+
return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature")
14+
415

16+
@pytest.fixture()
17+
def mock_http_client():
18+
return Mock()
519

6-
def test_execute_without_explicit_host(client):
7-
method = Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature")
8-
request = client.bot.sync_client.build_request(method)
20+
21+
def test_execute_without_explicit_host(client, token_method):
22+
request = client.bot.sync_client.build_request(token_method)
923

1024
assert client.bot.sync_client.execute(request)
25+
26+
27+
def test_raising_connection_error(client, token_method, mock_http_client):
28+
request = Request(token_method.http_method, token_method.url)
29+
mock_http_client.request.side_effect = ConnectError("Test error", request=request)
30+
31+
client.bot.sync_client.http_client = mock_http_client
32+
botx_request = client.bot.sync_client.build_request(token_method)
33+
34+
with pytest.raises(BotXConnectError):
35+
client.bot.sync_client.execute(botx_request)
36+
37+
38+
def test_raising_decode_error(client, token_method, mock_http_client):
39+
response = Response(status_code=418, text="Wrong json")
40+
mock_http_client.request.return_value = response
41+
42+
client.bot.sync_client.http_client = mock_http_client
43+
botx_request = client.bot.sync_client.build_request(token_method)
44+
45+
with pytest.raises(BotXJSONDecodeError):
46+
client.bot.sync_client.execute(botx_request)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from botx.clients.methods.base import BotXMethod
2+
3+
4+
class TestMethod(BotXMethod):
5+
__url__ = "/path/to/example"
6+
__method__ = "GET"
7+
__returning__ = str
8+
9+
10+
def test_method_empty_error_handlers():
11+
test_method = TestMethod()
12+
13+
assert test_method.__errors_handlers__ == {}

0 commit comments

Comments
 (0)