From c3844bb18fc3372d63bccb552e7056476ab71ac3 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 00:34:50 +0300 Subject: [PATCH] Added detection of API Errors and fixed coverage --- aiogram/client/errors_middleware.py | 64 ----------- aiogram/client/session/base.py | 32 ++++-- aiogram/utils/exceptions/bad_request.py | 4 +- aiogram/utils/exceptions/base.py | 15 +-- aiogram/utils/exceptions/conflict.py | 5 + aiogram/utils/exceptions/exceptions.py | 92 +--------------- aiogram/utils/exceptions/network.py | 4 + aiogram/utils/exceptions/not_found.py | 4 +- aiogram/utils/exceptions/server.py | 4 + aiogram/utils/exceptions/special.py | 4 +- aiogram/utils/exceptions/unauthorized.py | 5 + aiogram/utils/exceptions/util.py | 20 ---- tests/mocked_bot.py | 2 +- .../test_session/test_aiohttp_session.py | 19 ++++ .../test_session/test_base_session.py | 100 +++++++++++++++--- tests/test_dispatcher/test_dispatcher.py | 19 ++++ tests/test_dispatcher/test_router.py | 2 +- 17 files changed, 179 insertions(+), 216 deletions(-) delete mode 100644 aiogram/client/errors_middleware.py delete mode 100644 aiogram/utils/exceptions/util.py diff --git a/aiogram/client/errors_middleware.py b/aiogram/client/errors_middleware.py deleted file mode 100644 index 59d95f07..00000000 --- a/aiogram/client/errors_middleware.py +++ /dev/null @@ -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 diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py index 2e752e72..0a8c2973 100644 --- a/aiogram/client/session/base.py +++ b/aiogram/client/session/base.py @@ -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, diff --git a/aiogram/utils/exceptions/bad_request.py b/aiogram/utils/exceptions/bad_request.py index 9b9d878a..de1a0e2d 100644 --- a/aiogram/utils/exceptions/bad_request.py +++ b/aiogram/utils/exceptions/bad_request.py @@ -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 diff --git a/aiogram/utils/exceptions/base.py b/aiogram/utils/exceptions/base.py index fdbc2514..fefd3db8 100644 --- a/aiogram/utils/exceptions/base.py +++ b/aiogram/utils/exceptions/base.py @@ -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 diff --git a/aiogram/utils/exceptions/conflict.py b/aiogram/utils/exceptions/conflict.py index e69de29b..965b328c 100644 --- a/aiogram/utils/exceptions/conflict.py +++ b/aiogram/utils/exceptions/conflict.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class ConflictError(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/exceptions.py b/aiogram/utils/exceptions/exceptions.py index 4ce6f0c2..de1a0e2d 100644 --- a/aiogram/utils/exceptions/exceptions.py +++ b/aiogram/utils/exceptions/exceptions.py @@ -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.+)" - ] - - 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\d), expected "\w+)>", 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.+)" - ] - - 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.+)" at byte offset (?P\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) diff --git a/aiogram/utils/exceptions/network.py b/aiogram/utils/exceptions/network.py index 90a31041..b802ce69 100644 --- a/aiogram/utils/exceptions/network.py +++ b/aiogram/utils/exceptions/network.py @@ -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" diff --git a/aiogram/utils/exceptions/not_found.py b/aiogram/utils/exceptions/not_found.py index 8dfb344b..6fa87a06 100644 --- a/aiogram/utils/exceptions/not_found.py +++ b/aiogram/utils/exceptions/not_found.py @@ -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 diff --git a/aiogram/utils/exceptions/server.py b/aiogram/utils/exceptions/server.py index e9b5f365..c45c9f01 100644 --- a/aiogram/utils/exceptions/server.py +++ b/aiogram/utils/exceptions/server.py @@ -3,3 +3,7 @@ from aiogram.utils.exceptions.base import TelegramAPIError class ServerError(TelegramAPIError): pass + + +class RestartingTelegram(ServerError): + pass diff --git a/aiogram/utils/exceptions/special.py b/aiogram/utils/exceptions/special.py index 0568f900..d2044ec2 100644 --- a/aiogram/utils/exceptions/special.py +++ b/aiogram/utils/exceptions/special.py @@ -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}" ) diff --git a/aiogram/utils/exceptions/unauthorized.py b/aiogram/utils/exceptions/unauthorized.py index e69de29b..789574a5 100644 --- a/aiogram/utils/exceptions/unauthorized.py +++ b/aiogram/utils/exceptions/unauthorized.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class UnauthorizedError(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/util.py b/aiogram/utils/exceptions/util.py deleted file mode 100644 index a7cb191e..00000000 --- a/aiogram/utils/exceptions/util.py +++ /dev/null @@ -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 diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py index 03e48e4f..76abc445 100644 --- a/tests/mocked_bot.py +++ b/tests/mocked_bot.py @@ -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]: diff --git a/tests/test_api/test_client/test_session/test_aiohttp_session.py b/tests/test_api/test_client/test_session/test_aiohttp_session.py index 9624642e..e3149a29 100644 --- a/tests/test_api/test_client/test_session/test_aiohttp_session.py +++ b/tests/test_api/test_client/test_session/test_aiohttp_session.py @@ -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( diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index ef82c1d3..c30b63ea 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -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 diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 5bcbd9f6..9b9dd8fe 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -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() diff --git a/tests/test_dispatcher/test_router.py b/tests/test_dispatcher/test_router.py index 1516c33c..cee9e1bf 100644 --- a/tests/test_dispatcher/test_router.py +++ b/tests/test_dispatcher/test_router.py @@ -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