Added detection of API Errors and fixed coverage

This commit is contained in:
Alex Root Junior 2021-08-01 00:34:50 +03:00
parent 4f2cc75951
commit c3844bb18f
17 changed files with 179 additions and 216 deletions

View file

@ -1,64 +0,0 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, List, Type
from aiogram.methods import Response, TelegramMethod
from aiogram.types import TelegramObject
from aiogram.utils.exceptions.base import TelegramAPIError
from aiogram.utils.exceptions.exceptions import (
CantParseEntitiesStartTag,
CantParseEntitiesUnclosed,
CantParseEntitiesUnmatchedTags,
CantParseEntitiesUnsupportedTag,
DetailedTelegramAPIError,
)
if TYPE_CHECKING:
from aiogram.client.bot import Bot
from aiogram.client.session.base import NextRequestMiddlewareType
class RequestErrorMiddleware:
def __init__(self) -> None:
self._registry: List[Type[DetailedTelegramAPIError]] = [
CantParseEntitiesStartTag,
CantParseEntitiesUnmatchedTags,
CantParseEntitiesUnclosed,
CantParseEntitiesUnsupportedTag,
]
def mount(self, error: Type[DetailedTelegramAPIError]) -> Type[DetailedTelegramAPIError]:
if error in self:
raise ValueError(f"{error!r} is already registered")
if not hasattr(error, "patterns"):
raise ValueError(f"{error!r} has no attribute 'patterns'")
self._registry.append(error)
return error
def detect_error(self, err: TelegramAPIError) -> TelegramAPIError:
message = err.message
for variant in self._registry:
for pattern in variant.patterns:
if match := re.match(pattern, message):
return variant(
method=err.method,
message=err.message,
match=match,
)
return err
def __contains__(self, item: Type[DetailedTelegramAPIError]) -> bool:
return item in self._registry
async def __call__(
self,
bot: Bot,
method: TelegramMethod[TelegramObject],
make_request: NextRequestMiddlewareType,
) -> Response[TelegramObject]:
try:
return await make_request(bot, method)
except TelegramAPIError as e:
detected_err = self.detect_error(err=e)
raise detected_err from e

View file

@ -4,6 +4,7 @@ import abc
import datetime
import json
from functools import partial
from http import HTTPStatus
from types import TracebackType
from typing import (
TYPE_CHECKING,
@ -25,8 +26,13 @@ from aiogram.utils.helper import Default
from ...methods import Response, TelegramMethod
from ...methods.base import TelegramType
from ...types import UNSET, TelegramObject
from ...utils.exceptions.bad_request import BadRequest
from ...utils.exceptions.conflict import ConflictError
from ...utils.exceptions.network import EntityTooLarge
from ...utils.exceptions.not_found import NotFound
from ...utils.exceptions.server import RestartingTelegram, ServerError
from ...utils.exceptions.special import MigrateToChat, RetryAfter
from ..errors_middleware import RequestErrorMiddleware
from ...utils.exceptions.unauthorized import UnauthorizedError
from ..telegram import PRODUCTION, TelegramAPIServer
if TYPE_CHECKING: # pragma: no cover
@ -55,12 +61,8 @@ class BaseSession(abc.ABC):
timeout: Default[float] = Default(fget=lambda self: float(self.__class__.default_timeout))
"""Session scope request timeout"""
errors_middleware: ClassVar[RequestErrorMiddleware] = RequestErrorMiddleware()
def __init__(self) -> None:
self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [
self.errors_middleware,
]
self.middlewares: List[RequestMiddlewareType[TelegramObject]] = []
def check_response(
self, method: TelegramMethod[TelegramType], status_code: int, content: str
@ -70,10 +72,11 @@ class BaseSession(abc.ABC):
"""
json_data = self.json_loads(content)
response = method.build_response(json_data)
if response.ok:
if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED and response.ok:
return response
description = cast(str, response.description)
if parameters := response.parameters:
if parameters.retry_after:
raise RetryAfter(
@ -85,6 +88,21 @@ class BaseSession(abc.ABC):
message=description,
migrate_to_chat_id=parameters.migrate_to_chat_id,
)
if status_code == HTTPStatus.BAD_REQUEST:
raise BadRequest(method=method, message=description)
if status_code == HTTPStatus.NOT_FOUND:
raise NotFound(method=method, message=description)
if status_code == HTTPStatus.CONFLICT:
raise ConflictError(method=method, message=description)
if status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
raise UnauthorizedError(method=method, message=description)
if status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
raise EntityTooLarge(method=method, message=description)
if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
if "restart" in description:
raise RestartingTelegram(method=method, message=description)
raise ServerError(method=method, message=description)
raise TelegramAPIError(
method=method,
message=description,

View file

@ -1,5 +1,5 @@
from aiogram.utils.exceptions.base import DetailedTelegramAPIError
from aiogram.utils.exceptions.base import TelegramAPIError
class BadRequest(DetailedTelegramAPIError):
class BadRequest(TelegramAPIError):
pass

View file

@ -1,4 +1,4 @@
from typing import ClassVar, List, Match, Optional, TypeVar
from typing import Optional, TypeVar
from aiogram.methods import TelegramMethod
from aiogram.methods.base import TelegramType
@ -25,16 +25,3 @@ class TelegramAPIError(Exception):
if self.url:
message.append(f"(background on this error at: {self.url})")
return "\n".join(message)
class DetailedTelegramAPIError(TelegramAPIError):
patterns: ClassVar[List[str]]
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
match: Match[str],
) -> None:
super().__init__(method=method, message=message)
self.match: Match[str] = match

View file

@ -0,0 +1,5 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class ConflictError(TelegramAPIError):
pass

View file

@ -1,93 +1,5 @@
from textwrap import indent
from typing import Match
from aiogram.methods.base import TelegramMethod, TelegramType
from aiogram.utils.exceptions.base import DetailedTelegramAPIError
from aiogram.utils.exceptions.util import mark_line
from aiogram.utils.exceptions.base import TelegramAPIError
class BadRequest(DetailedTelegramAPIError):
class BadRequest(TelegramAPIError):
pass
class CantParseEntities(BadRequest):
pass
class CantParseEntitiesStartTag(CantParseEntities):
patterns = [
"Bad Request: can't parse entities: Can't find end tag corresponding to start tag (?P<tag>.+)"
]
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
match: Match[str],
) -> None:
super().__init__(method=method, message=message, match=match)
self.tag: str = match.group("tag")
class CantParseEntitiesUnmatchedTags(CantParseEntities):
patterns = [
r'Bad Request: can\'t parse entities: Unmatched end tag at byte offset (?P<offset>\d), expected "</(?P<expected>\w+)>", found "</(?P<found>\w+)>"'
]
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
match: Match[str],
) -> None:
super().__init__(method=method, message=message, match=match)
self.offset: int = int(match.group("offset"))
self.expected: str = match.group("expected")
self.found: str = match.group("found")
class CantParseEntitiesUnclosed(CantParseEntities):
patterns = [
"Bad Request: can't parse entities: Unclosed start tag at byte offset (?P<offset>.+)"
]
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
match: Match[str],
) -> None:
super().__init__(method=method, message=message, match=match)
self.offset: int = int(match.group("offset"))
def __str__(self) -> str:
message = [self.message]
text = getattr(self.method, "text", None) or getattr(self.method, "caption", None)
if text:
message.extend(["Example:", indent(mark_line(text, self.offset), prefix=" ")])
return "\n".join(message)
class CantParseEntitiesUnsupportedTag(CantParseEntities):
patterns = [
r'Bad Request: can\'t parse entities: Unsupported start tag "(?P<tag>.+)" at byte offset (?P<offset>\d+)'
]
def __init__(
self,
method: TelegramMethod[TelegramType],
message: str,
match: Match[str],
) -> None:
super().__init__(method=method, message=message, match=match)
self.offset = int(match.group("offset"))
self.tag = match.group("tag")
def __str__(self) -> str:
message = [self.message]
text = getattr(self.method, "text", None) or getattr(self.method, "caption", None)
if text:
message.extend(
["Example:", indent(mark_line(text, self.offset, len(self.tag)), prefix=" ")]
)
return "\n".join(message)

View file

@ -3,3 +3,7 @@ from aiogram.utils.exceptions.base import TelegramAPIError
class NetworkError(TelegramAPIError):
pass
class EntityTooLarge(NetworkError):
url = "https://core.telegram.org/bots/api#sending-files"

View file

@ -1,5 +1,5 @@
from aiogram.utils.exceptions.base import DetailedTelegramAPIError
from aiogram.utils.exceptions.base import TelegramAPIError
class NotFound(DetailedTelegramAPIError):
class NotFound(TelegramAPIError):
pass

View file

@ -3,3 +3,7 @@ from aiogram.utils.exceptions.base import TelegramAPIError
class ServerError(TelegramAPIError):
pass
class RestartingTelegram(ServerError):
pass

View file

@ -1,5 +1,3 @@
from typing import Optional
from aiogram.methods import TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.utils.exceptions.base import TelegramAPIError
@ -37,7 +35,7 @@ class MigrateToChat(TelegramAPIError):
super().__init__(method=method, message=message)
self.migrate_to_chat_id = migrate_to_chat_id
def render_message(self) -> Optional[str]:
def render_description(self) -> str:
description = (
f"The group has been migrated to a supergroup with id {self.migrate_to_chat_id}"
)

View file

@ -0,0 +1,5 @@
from aiogram.utils.exceptions.base import TelegramAPIError
class UnauthorizedError(TelegramAPIError):
pass

View file

@ -1,20 +0,0 @@
def mark_line(text: str, offset: int, length: int = 1) -> str:
try:
if offset > 0 and (new_line_pos := text[:offset].rindex("\n")):
text = "..." + text[:new_line_pos]
offset -= new_line_pos - 3
except ValueError:
pass
if offset > 10:
text = "..." + text[offset - 10 :]
offset = 13
mark = " " * offset
mark += "^" * length
try:
if new_line_pos := text[len(mark) :].index("\n"):
text = text[:new_line_pos].rstrip() + "..."
except ValueError:
pass
return text + "\n" + mark

View file

@ -53,7 +53,7 @@ class MockedBot(Bot):
ok: bool,
result: TelegramType = None,
description: Optional[str] = None,
error_code: Optional[int] = None,
error_code: int = 200,
migrate_to_chat_id: Optional[int] = None,
retry_after: Optional[int] = None,
) -> Response[TelegramType]:

View file

@ -1,7 +1,9 @@
import asyncio
from typing import AsyncContextManager, AsyncGenerator
import aiohttp_socks
import pytest
from aiohttp import ClientError
from aresponses import ResponsesMockServer
from aiogram import Bot
@ -9,6 +11,7 @@ from aiogram.client.session import aiohttp
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.methods import Request, TelegramMethod
from aiogram.types import UNSET, InputFile
from aiogram.utils.exceptions.network import NetworkError
from tests.mocked_bot import MockedBot
try:
@ -177,6 +180,22 @@ class TestAiohttpSession:
assert isinstance(result, int)
assert result == 42
@pytest.mark.parametrize("error", [ClientError("mocked"), asyncio.TimeoutError()])
@pytest.mark.asyncio
async def test_make_request_network_error(self, error):
bot = Bot("42:TEST")
async def side_effect(*args, **kwargs):
raise error
with patch(
"aiohttp.client.ClientSession._request",
new_callable=CoroutineMock,
side_effect=side_effect,
):
with pytest.raises(NetworkError):
await bot.get_me()
@pytest.mark.asyncio
async def test_stream_content(self, aresponses: ResponsesMockServer):
aresponses.add(

View file

@ -4,10 +4,20 @@ from typing import AsyncContextManager, AsyncGenerator, Optional
import pytest
from aiogram import Bot
from aiogram.client.session.base import BaseSession, TelegramType
from aiogram.client.telegram import PRODUCTION, TelegramAPIServer
from aiogram.methods import DeleteMessage, GetMe, TelegramMethod
from aiogram.types import UNSET
from aiogram.types import UNSET, User
from aiogram.utils.exceptions.bad_request import BadRequest
from aiogram.utils.exceptions.base import TelegramAPIError
from aiogram.utils.exceptions.conflict import ConflictError
from aiogram.utils.exceptions.network import EntityTooLarge
from aiogram.utils.exceptions.not_found import NotFound
from aiogram.utils.exceptions.server import RestartingTelegram, ServerError
from aiogram.utils.exceptions.special import MigrateToChat, RetryAfter
from aiogram.utils.exceptions.unauthorized import UnauthorizedError
from tests.mocked_bot import MockedBot
try:
from asynctest import CoroutineMock, patch
@ -137,20 +147,53 @@ class TestBaseSession:
assert session.clean_json(42) == 42
def check_response(self):
@pytest.mark.parametrize(
"status_code,content,error",
[
[200, '{"ok":true,"result":true}', None],
[400, '{"ok":false,"description":"test"}', BadRequest],
[
400,
'{"ok":false,"description":"test", "parameters": {"retry_after": 1}}',
RetryAfter,
],
[
400,
'{"ok":false,"description":"test", "parameters": {"migrate_to_chat_id": -42}}',
MigrateToChat,
],
[404, '{"ok":false,"description":"test"}', NotFound],
[401, '{"ok":false,"description":"test"}', UnauthorizedError],
[403, '{"ok":false,"description":"test"}', UnauthorizedError],
[409, '{"ok":false,"description":"test"}', ConflictError],
[413, '{"ok":false,"description":"test"}', EntityTooLarge],
[500, '{"ok":false,"description":"restarting"}', RestartingTelegram],
[500, '{"ok":false,"description":"test"}', ServerError],
[502, '{"ok":false,"description":"test"}', ServerError],
[499, '{"ok":false,"description":"test"}', TelegramAPIError],
[499, '{"ok":false,"description":"test"}', TelegramAPIError],
],
)
def test_check_response(self, status_code, content, error):
session = CustomSession()
session.check_response(
method=DeleteMessage(chat_id=42, message_id=42),
status_code=200,
content='{"ok":true,"result":true}',
)
with pytest.raises(Exception):
method = DeleteMessage(chat_id=42, message_id=42)
if error is None:
session.check_response(
method=DeleteMessage(chat_id=42, message_id=42),
status_code=400,
content='{"ok":false,"description":"test"}',
method=method,
status_code=status_code,
content=content,
)
else:
with pytest.raises(error) as exc_info:
session.check_response(
method=method,
status_code=status_code,
content=content,
)
error: TelegramAPIError = exc_info.value
string = str(error)
if error.url:
assert error.url in string
@pytest.mark.asyncio
async def test_make_request(self):
@ -181,3 +224,36 @@ class TestBaseSession:
async with session as ctx:
assert session == ctx
mocked_close.assert_awaited_once()
def test_add_middleware(self):
async def my_middleware(bot, method, make_request):
return await make_request(bot, method)
session = CustomSession()
assert not session.middlewares
session.middleware(my_middleware)
assert my_middleware in session.middlewares
assert len(session.middlewares) == 1
@pytest.mark.asyncio
async def test_use_middleware(self, bot: MockedBot):
flag_before = False
flag_after = False
@bot.session.middleware
async def my_middleware(b, method, make_request):
nonlocal flag_before, flag_after
flag_before = True
try:
assert isinstance(b, Bot)
assert isinstance(method, TelegramMethod)
return await make_request(bot, method)
finally:
flag_after = True
bot.add_result_for(GetMe, ok=True, result=User(id=42, is_bot=True, first_name="Test"))
assert await bot.get_me()
assert flag_before
assert flag_after

View file

@ -49,6 +49,10 @@ async def invalid_message_handler(message: Message):
raise Exception(42)
async def anext(ait):
return await ait.__anext__()
RAW_UPDATE = {
"update_id": 42,
"message": {
@ -147,6 +151,21 @@ class TestDispatcher:
break
assert index == 42
@pytest.mark.asyncio
async def test_listen_update_with_error(self, bot: MockedBot):
dispatcher = Dispatcher()
listen = dispatcher._listen_updates(bot=bot)
bot.add_result_for(
GetUpdates, ok=True, result=[Update(update_id=update_id) for update_id in range(42)]
)
bot.add_result_for(GetUpdates, ok=False, error_code=500, description="restarting")
with patch(
"aiogram.utils.backoff.Backoff.asleep",
new_callable=CoroutineMock,
) as mocked_asleep:
assert isinstance(await anext(listen), Update)
assert mocked_asleep.awaited
@pytest.mark.asyncio
async def test_silent_call_request(self, bot: MockedBot, caplog):
dispatcher = Dispatcher()

View file

@ -1,6 +1,6 @@
import pytest
from aiogram.dispatcher.event.bases import SkipHandler, skip, UNHANDLED
from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler, skip
from aiogram.dispatcher.router import Router
from aiogram.utils.warnings import CodeHasNoEffect