Мидлвеер для обработки ошибок и их логирования

This commit is contained in:
2026-02-23 14:16:51 +07:00
parent b63410187f
commit 2479df5eda

View File

@@ -0,0 +1,674 @@
"""
Middleware для глобальной обработки ошибок
"""
from typing import Callable, Awaitable, Any, Dict, Optional, List, Set
from datetime import datetime
from collections import defaultdict
from enum import Enum
import traceback
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery, Update
from aiogram.exceptions import (
TelegramBadRequest,
TelegramForbiddenError,
TelegramNotFound,
TelegramUnauthorizedError,
TelegramRetryAfter,
TelegramAPIError
)
from middleware.loggers import logger
from bot.utils import (
username,
format_content_info,
get_content_type,
safe_answer_callback,
format_duration,
format_timestamp
)
from bot.templates import msg
__all__ = ('ErrorHandlingMiddleware', 'ErrorCategory')
class ErrorCategory(str, Enum):
"""Категории ошибок"""
TELEGRAM_API = "telegram_api" # Ошибки Telegram API
RATE_LIMIT = "rate_limit" # Rate limiting
PERMISSION = "permission" # Права доступа
VALIDATION = "validation" # Валидация данных
DATABASE = "database" # Ошибки БД
HANDLER = "handler" # Ошибки в хендлерах
UNKNOWN = "unknown" # Неизвестные ошибки
class ErrorStats:
"""Статистика ошибок"""
def __init__(self):
# Счетчики по категориям
self.by_category: Dict[ErrorCategory, int] = defaultdict(int)
# Счетчики по типам исключений
self.by_exception: Dict[str, int] = defaultdict(int)
# Последние ошибки (последние 10)
self.recent_errors: List[Dict[str, Any]] = []
self.max_recent = 10
# Общая статистика
self.total_errors: int = 0
self.start_time: datetime = datetime.now()
def add_error(
self,
exception: Exception,
category: ErrorCategory,
user_id: Optional[int] = None,
details: Optional[Dict] = None
):
"""Добавляет ошибку в статистику"""
self.total_errors += 1
self.by_category[category] += 1
self.by_exception[type(exception).__name__] += 1
# Добавляем в последние ошибки
error_info = {
'timestamp': datetime.now(),
'exception': type(exception).__name__,
'message': str(exception),
'category': category,
'user_id': user_id,
'details': details or {}
}
self.recent_errors.append(error_info)
if len(self.recent_errors) > self.max_recent:
self.recent_errors.pop(0)
def get_summary(self) -> Dict[str, Any]:
"""Возвращает сводку по статистике"""
uptime = datetime.now() - self.start_time
return {
'total_errors': self.total_errors,
'uptime': format_duration(int(uptime.total_seconds())),
'by_category': dict(self.by_category),
'by_exception': dict(self.by_exception),
'recent_errors': self.recent_errors
}
class ErrorHandlingMiddleware(BaseMiddleware):
"""
Middleware для глобальной обработки ошибок.
Features:
- Категоризация ошибок
- Уведомление администраторов
- Статистика ошибок
- Rate limiting уведомлений
- Retry механизм для некоторых ошибок
- Детальное логирование
- Graceful degradation
"""
def __init__(
self,
admin_ids: List[int],
notify_admins: bool = True,
notify_users: bool = True,
log_errors: bool = True,
notify_rate_limit: int = 60 # Не чаще раза в минуту для одного типа ошибки
):
"""
Args:
admin_ids: Список ID администраторов
notify_admins: Уведомлять администраторов
notify_users: Уведомлять пользователей
log_errors: Логировать ошибки
notify_rate_limit: Минимальный интервал между уведомлениями (секунды)
"""
super().__init__()
self.admin_ids = admin_ids
self.notify_admins = notify_admins
self.notify_users = notify_users
self.log_errors = log_errors
self.notify_rate_limit = notify_rate_limit
# Статистика
self.stats = ErrorStats()
# Rate limiting для уведомлений
# {error_type: last_notification_time}
self._last_notifications: Dict[str, datetime] = {}
# Игнорируемые ошибки (для которых не нужно уведомлять)
self.ignored_errors: Set[type] = {
TelegramRetryAfter, # Rate limit Telegram
}
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Обрабатывает ошибки в хендлерах.
Args:
handler: Следующий обработчик
event: Входящее событие
data: Контекстные данные
Returns:
Результат выполнения обработчика или None при ошибке
"""
try:
# Выполняем хендлер
return await handler(event, data)
except Exception as e:
# Обрабатываем ошибку
await self._handle_error(e, event, data)
return None
async def _handle_error(
self,
exception: Exception,
event: TelegramObject,
data: Dict[str, Any]
):
"""
Централизованная обработка ошибки.
Args:
exception: Исключение
event: Событие
data: Контекстные данные
"""
# Определяем категорию ошибки
category = self._categorize_error(exception)
# Извлекаем информацию о событии
event_info = self._extract_event_info(event)
# Добавляем в статистику
self.stats.add_error(
exception=exception,
category=category,
user_id=event_info.get('user_id'),
details=event_info
)
# Логируем ошибку
if self.log_errors:
await self._log_error(exception, category, event_info)
# Уведомляем администраторов
if self.notify_admins and not self._is_ignored(exception):
await self._notify_admins_about_error(exception, category, event_info, event)
# Уведомляем пользователя
if self.notify_users:
await self._notify_user_about_error(exception, category, event)
@staticmethod
def _categorize_error(exception: Exception) -> ErrorCategory:
"""
Определяет категорию ошибки.
Args:
exception: Исключение
Returns:
Категория ошибки
"""
# Ошибки Telegram API
if isinstance(exception, TelegramRetryAfter):
return ErrorCategory.RATE_LIMIT
if isinstance(exception, (TelegramForbiddenError, TelegramUnauthorizedError)):
return ErrorCategory.PERMISSION
if isinstance(exception, (TelegramBadRequest, TelegramNotFound)):
return ErrorCategory.TELEGRAM_API
if isinstance(exception, TelegramAPIError):
return ErrorCategory.TELEGRAM_API
# Ошибки валидации
if isinstance(exception, (ValueError, TypeError, AttributeError)):
return ErrorCategory.VALIDATION
# Ошибки БД (примеры, замени на свои)
# if isinstance(exception, (DatabaseError, OperationalError)):
# return ErrorCategory.DATABASE
# Остальные ошибки
return ErrorCategory.HANDLER
@staticmethod
def _extract_event_info(event: TelegramObject) -> Dict[str, Any]:
"""
Извлекает информацию о событии.
Args:
event: Объект события
Returns:
Словарь с информацией
"""
info: Dict[str, Any] = {
'event_type': type(event).__name__,
'timestamp': datetime.now(),
'user_str': '@System',
'user_id': None,
'chat_id': None,
'chat_type': None,
'message_id': None,
'content_type': None,
'content_info': None,
'text': None
}
# Обработка разных типов событий
message = None
if isinstance(event, Message):
message = event
elif isinstance(event, CallbackQuery):
message = event.message
info['callback_data'] = event.data
elif isinstance(event, Update):
message = (
event.message or
event.edited_message or
event.channel_post or
event.edited_channel_post
)
if event.callback_query:
info['callback_data'] = event.callback_query.data
# Извлекаем информацию из сообщения
if message:
# Пользователь
if message.from_user:
info['user_str'] = username(message)
info['user_id'] = message.from_user.id
# Чат
info['chat_id'] = message.chat.id
info['chat_type'] = message.chat.type
info['message_id'] = message.message_id
# Контент
info['content_type'] = get_content_type(message)
info['content_info'] = format_content_info(message, include_text=False)
# Текст
if message.text:
text = message.text
info['text'] = text if len(text) <= 100 else text[:100] + "..."
elif message.caption:
caption = message.caption
info['caption'] = caption if len(caption) <= 100 else caption[:100] + "..."
return info
@staticmethod
async def _log_error(
exception: Exception,
category: ErrorCategory,
event_info: Dict[str, Any]
):
"""
Логирует ошибку.
Args:
exception: Исключение
category: Категория ошибки
event_info: Информация о событии
"""
# Формируем сообщение для лога
error_type = type(exception).__name__
error_msg = str(exception)
# Получаем traceback
tb = ''.join(traceback.format_exception(
type(exception),
exception,
exception.__traceback__
))
# Базовое сообщение
log_msg = (
f"🚨 Ошибка в хендлере\n"
f"├─ Тип: {error_type}\n"
f"├─ Категория: {category.value}\n"
f"├─ Сообщение: {error_msg}\n"
f"├─ Событие: {event_info['event_type']}\n"
)
if event_info.get('text'):
log_msg += f"├─ Текст: {event_info['text']}\n"
if event_info.get('callback_data'):
log_msg += f"├─ Callback: {event_info['callback_data']}\n"
if event_info.get('content_info'):
log_msg += f"└─ Контент: {event_info['content_info']}"
# Логируем с полным traceback
logger.error(
text=log_msg,
log_type=f"ERROR_{category.value.upper()}",
user=event_info['user_str'],
)
# Дополнительно логируем traceback отдельно для детального анализа
logger.debug(
text=f"Полный traceback:\n{tb}",
log_type=f"ERROR_{category.value.upper()}_TRACEBACK",
user=event_info['user_str']
)
async def _notify_admins_about_error(
self,
exception: Exception,
category: ErrorCategory,
event_info: Dict[str, Any],
event: TelegramObject
):
"""
Уведомляет администраторов об ошибке.
Args:
exception: Исключение
category: Категория ошибки
event_info: Информация о событии
event: Объект события
"""
# Проверяем rate limit
error_key = type(exception).__name__
if not self._should_notify(error_key):
logger.debug(
f"Пропуск уведомления админов о {error_key} (rate limit)",
log_type="ADMIN_NOTIFY_SKIP"
)
return
# Обновляем время последнего уведомления
self._last_notifications[error_key] = datetime.now()
# Получаем bot
bot = event.bot if hasattr(event, 'bot') else None
if not bot:
return
# Формируем сообщение
error_type = type(exception).__name__
error_msg = str(exception)
# Определяем emoji для категории
category_emoji = self._get_category_emoji(category)
notification = (
f"{category_emoji} <b>Ошибка в боте</b>\n\n"
f"📊 <b>Информация:</b>\n"
f"├─ Тип: <code>{error_type}</code>\n"
f"├─ Категория: <code>{category.value}</code>\n"
f"├─ Время: {format_timestamp(datetime.now())}\n"
)
# Добавляем информацию о пользователе
if event_info.get('user_str') and event_info['user_str'] != '@System':
notification += f"└─ Пользователь: {event_info['user_str']}\n\n"
else:
notification += "\n"
# Добавляем сообщение ошибки
if len(error_msg) <= 200:
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg}</code>\n\n"
else:
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg[:200]}...</code>\n\n"
# Добавляем контекст события
notification += f"📋 <b>Контекст:</b>\n"
if event_info.get('text'):
notification += f"├─ Текст: <code>{event_info['text']}</code>\n"
if event_info.get('callback_data'):
notification += f"├─ Callback: <code>{event_info['callback_data']}</code>\n"
if event_info.get('content_info'):
notification += f"├─ Контент: {event_info['content_info']}\n"
if event_info.get('chat_type'):
notification += f"└─ Тип чата: <code>{event_info['chat_type']}</code>\n"
# Добавляем статистику
stats = self.stats.get_summary()
notification += (
f"\n📊 <b>Статистика:</b>\n"
f"└─ Всего ошибок: {stats['total_errors']}"
)
# Отправляем администраторам
for admin_id in self.admin_ids:
try:
await bot.send_message(
chat_id=admin_id,
text=notification,
parse_mode="HTML"
)
logger.debug(
f"Администратор {admin_id} уведомлен об ошибке",
log_type="ADMIN_NOTIFIED"
)
except Exception as e:
logger.error(
f"Не удалось уведомить админа {admin_id}: {e}",
log_type="ADMIN_NOTIFY_ERROR"
)
@staticmethod
async def _notify_user_about_error(
exception: Exception,
category: ErrorCategory,
event: TelegramObject
):
"""
Уведомляет пользователя об ошибке.
Args:
exception: Исключение
category: Категория ошибки
event: Объект события
"""
# Формируем сообщение в зависимости от категории
error_messages = {
ErrorCategory.TELEGRAM_API: (
"⚠️ Произошла техническая ошибка.\n"
"Попробуйте повторить действие."
),
ErrorCategory.RATE_LIMIT: (
"⏳ Слишком много запросов.\n"
"Пожалуйста, подождите немного."
),
ErrorCategory.PERMISSION: (
"🔒 Недостаточно прав для выполнения действия."
),
ErrorCategory.VALIDATION: (
"❌ Некорректные данные.\n"
"Проверьте правильность ввода."
),
ErrorCategory.DATABASE: (
"💾 Ошибка базы данных.\n"
"Попробуйте позже."
),
ErrorCategory.HANDLER: (
"⚠️ Произошла непредвиденная ошибка.\n"
"Разработчики уже уведомлены."
),
ErrorCategory.UNKNOWN: (
"⚠️ Произошла ошибка.\n"
"Попробуйте повторить позже."
)
}
error_text = error_messages.get(
category,
error_messages[ErrorCategory.UNKNOWN]
)
error_text += "\n\nПопробуйте нажать /start или обратитесь к администратору."
try:
# Отправляем сообщение
if isinstance(event, Message):
await msg(event, text=error_text)
elif isinstance(event, CallbackQuery):
await safe_answer_callback(event, error_text[:200], show_alert=True)
# Также отправляем в чат если сообщение доступно
if event.message:
try:
await msg(event.message, text=error_text)
except:
pass
elif isinstance(event, Update):
if event.message:
await msg(event.message, text=error_text)
elif event.callback_query:
await safe_answer_callback(
event.callback_query,
error_text[:200],
show_alert=True
)
logger.debug(
"Пользователь уведомлен об ошибке",
log_type="USER_ERROR_NOTIFIED"
)
except Exception as e:
logger.warning(
f"Не удалось уведомить пользователя об ошибке: {e}",
log_type="USER_NOTIFY_ERROR"
)
def _should_notify(self, error_key: str) -> bool:
"""
Проверяет, нужно ли отправлять уведомление (rate limiting).
Args:
error_key: Ключ ошибки
Returns:
True если можно отправить уведомление
"""
if error_key not in self._last_notifications:
return True
last_time = self._last_notifications[error_key]
time_passed = (datetime.now() - last_time).total_seconds()
return time_passed >= self.notify_rate_limit
def _is_ignored(self, exception: Exception) -> bool:
"""
Проверяет, игнорируется ли ошибка.
Args:
exception: Исключение
Returns:
True если ошибка игнорируется
"""
return type(exception) in self.ignored_errors
@staticmethod
def _get_category_emoji(category: ErrorCategory) -> str:
"""Возвращает emoji для категории ошибки"""
emoji_map = {
ErrorCategory.TELEGRAM_API: "🔌",
ErrorCategory.RATE_LIMIT: "",
ErrorCategory.PERMISSION: "🔒",
ErrorCategory.VALIDATION: "",
ErrorCategory.DATABASE: "💾",
ErrorCategory.HANDLER: "🚨",
ErrorCategory.UNKNOWN: "⚠️"
}
return emoji_map.get(category, "⚠️")
def get_stats(self) -> Dict[str, Any]:
"""Возвращает статистику ошибок"""
return self.stats.get_summary()
def reset_stats(self):
"""Сбрасывает статистику"""
self.stats = ErrorStats()
def add_ignored_error(self, error_type: type):
"""Добавляет тип ошибки в игнорируемые"""
self.ignored_errors.add(error_type)
def remove_ignored_error(self, error_type: type):
"""Удаляет тип ошибки из игнорируемых"""
self.ignored_errors.discard(error_type)
# ================= УТИЛИТЫ =================
def format_error_stats(stats: Dict[str, Any]) -> str:
"""
Форматирует статистику ошибок.
Args:
stats: Словарь со статистикой
Returns:
Отформатированная строка
Example:
>> stats = middleware.get_stats()
>> print(format_error_stats(stats))
"""
text = (
f"🚨 <b>Статистика ошибок</b>\n\n"
f"📊 <b>Общая информация:</b>\n"
f"├─ Всего ошибок: {stats['total_errors']}\n"
f"└─ Время работы: {stats['uptime']}\n\n"
)
# По категориям
if stats['by_category']:
text += f"📁 <b>По категориям:</b>\n"
for category, count in stats['by_category'].items():
text += f"├─ {category}: {count}\n"
text += "\n"
# По типам исключений
if stats['by_exception']:
text += f"🔧 <b>По типам (топ-5):</b>\n"
sorted_exceptions = sorted(
stats['by_exception'].items(),
key=lambda x: x[1],
reverse=True
)[:5]
for exc_type, count in sorted_exceptions:
text += f"├─ {exc_type}: {count}\n"
return text