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 datetime
import json import json
from functools import partial from functools import partial
from http import HTTPStatus
from types import TracebackType from types import TracebackType
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@ -25,8 +26,13 @@ from aiogram.utils.helper import Default
from ...methods import Response, TelegramMethod from ...methods import Response, TelegramMethod
from ...methods.base import TelegramType from ...methods.base import TelegramType
from ...types import UNSET, TelegramObject 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 ...utils.exceptions.special import MigrateToChat, RetryAfter
from ..errors_middleware import RequestErrorMiddleware from ...utils.exceptions.unauthorized import UnauthorizedError
from ..telegram import PRODUCTION, TelegramAPIServer from ..telegram import PRODUCTION, TelegramAPIServer
if TYPE_CHECKING: # pragma: no cover 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)) timeout: Default[float] = Default(fget=lambda self: float(self.__class__.default_timeout))
"""Session scope request timeout""" """Session scope request timeout"""
errors_middleware: ClassVar[RequestErrorMiddleware] = RequestErrorMiddleware()
def __init__(self) -> None: def __init__(self) -> None:
self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [ self.middlewares: List[RequestMiddlewareType[TelegramObject]] = []
self.errors_middleware,
]
def check_response( def check_response(
self, method: TelegramMethod[TelegramType], status_code: int, content: str self, method: TelegramMethod[TelegramType], status_code: int, content: str
@ -70,10 +72,11 @@ class BaseSession(abc.ABC):
""" """
json_data = self.json_loads(content) json_data = self.json_loads(content)
response = method.build_response(json_data) response = method.build_response(json_data)
if response.ok: if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED and response.ok:
return response return response
description = cast(str, response.description) description = cast(str, response.description)
if parameters := response.parameters: if parameters := response.parameters:
if parameters.retry_after: if parameters.retry_after:
raise RetryAfter( raise RetryAfter(
@ -85,6 +88,21 @@ class BaseSession(abc.ABC):
message=description, message=description,
migrate_to_chat_id=parameters.migrate_to_chat_id, 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( raise TelegramAPIError(
method=method, method=method,
message=description, 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 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 import TelegramMethod
from aiogram.methods.base import TelegramType from aiogram.methods.base import TelegramType
@ -25,16 +25,3 @@ class TelegramAPIError(Exception):
if self.url: if self.url:
message.append(f"(background on this error at: {self.url})") message.append(f"(background on this error at: {self.url})")
return "\n".join(message) 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 aiogram.utils.exceptions.base import TelegramAPIError
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
class BadRequest(DetailedTelegramAPIError): class BadRequest(TelegramAPIError):
pass 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): class NetworkError(TelegramAPIError):
pass 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 pass

View file

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

View file

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

View file

@ -1,7 +1,9 @@
import asyncio
from typing import AsyncContextManager, AsyncGenerator from typing import AsyncContextManager, AsyncGenerator
import aiohttp_socks import aiohttp_socks
import pytest import pytest
from aiohttp import ClientError
from aresponses import ResponsesMockServer from aresponses import ResponsesMockServer
from aiogram import Bot from aiogram import Bot
@ -9,6 +11,7 @@ from aiogram.client.session import aiohttp
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.methods import Request, TelegramMethod from aiogram.methods import Request, TelegramMethod
from aiogram.types import UNSET, InputFile from aiogram.types import UNSET, InputFile
from aiogram.utils.exceptions.network import NetworkError
from tests.mocked_bot import MockedBot from tests.mocked_bot import MockedBot
try: try:
@ -177,6 +180,22 @@ class TestAiohttpSession:
assert isinstance(result, int) assert isinstance(result, int)
assert result == 42 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 @pytest.mark.asyncio
async def test_stream_content(self, aresponses: ResponsesMockServer): async def test_stream_content(self, aresponses: ResponsesMockServer):
aresponses.add( aresponses.add(

View file

@ -4,10 +4,20 @@ from typing import AsyncContextManager, AsyncGenerator, Optional
import pytest import pytest
from aiogram import Bot
from aiogram.client.session.base import BaseSession, TelegramType from aiogram.client.session.base import BaseSession, TelegramType
from aiogram.client.telegram import PRODUCTION, TelegramAPIServer from aiogram.client.telegram import PRODUCTION, TelegramAPIServer
from aiogram.methods import DeleteMessage, GetMe, TelegramMethod 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: try:
from asynctest import CoroutineMock, patch from asynctest import CoroutineMock, patch
@ -137,20 +147,53 @@ class TestBaseSession:
assert session.clean_json(42) == 42 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 = CustomSession()
method = DeleteMessage(chat_id=42, message_id=42)
if error is None:
session.check_response( session.check_response(
method=DeleteMessage(chat_id=42, message_id=42), method=method,
status_code=200, status_code=status_code,
content='{"ok":true,"result":true}', content=content,
) )
with pytest.raises(Exception): else:
with pytest.raises(error) as exc_info:
session.check_response( session.check_response(
method=DeleteMessage(chat_id=42, message_id=42), method=method,
status_code=400, status_code=status_code,
content='{"ok":false,"description":"test"}', content=content,
) )
error: TelegramAPIError = exc_info.value
string = str(error)
if error.url:
assert error.url in string
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_request(self): async def test_make_request(self):
@ -181,3 +224,36 @@ class TestBaseSession:
async with session as ctx: async with session as ctx:
assert session == ctx assert session == ctx
mocked_close.assert_awaited_once() 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) raise Exception(42)
async def anext(ait):
return await ait.__anext__()
RAW_UPDATE = { RAW_UPDATE = {
"update_id": 42, "update_id": 42,
"message": { "message": {
@ -147,6 +151,21 @@ class TestDispatcher:
break break
assert index == 42 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 @pytest.mark.asyncio
async def test_silent_call_request(self, bot: MockedBot, caplog): async def test_silent_call_request(self, bot: MockedBot, caplog):
dispatcher = Dispatcher() dispatcher = Dispatcher()

View file

@ -1,6 +1,6 @@
import pytest 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.dispatcher.router import Router
from aiogram.utils.warnings import CodeHasNoEffect from aiogram.utils.warnings import CodeHasNoEffect