fix: исправить блокировку event loop и добавить retry задержку

- Добавить экспоненциальную задержку между попытками retry (1с, 2с, 4с) в pogoda.py
- Заменить time.sleep на await asyncio.sleep для неблокирующих ожиданий
- Обернуть requests.get в asyncio.to_thread для предотвращения блокировки event loop (news.py, cat.py, pogoda.py)
- Добавить правило AGENTS.md: не использовать эмодзи
- Добавить ISSUES.md с фиксацией проблем проекта
This commit is contained in:
deadzilla 2026-05-26 10:24:26 +05:00
parent 0e11dbe6c1
commit d4bf8ca45d
6 changed files with 103 additions and 7 deletions

View File

@ -20,6 +20,7 @@ python bot.py
- Используй TODO-списки для каждого запроса с несколькими шагами.
- Пиши комментарии на русском.
- Обработка ошибок: try/except для всех внешних вызовов (API, БД, файловая система).
- **Никогда не используй эмодзи в тексте или выводах.**
## Архитектура
```

78
ISSUES.md Normal file
View File

@ -0,0 +1,78 @@
# Issues — список проблем проекта
> Состояние на: 2026-05-26
---
## 🔴 Критические
### 1. Retry без задержки между попытками ✅ РЕШЕНО
- **Где:** `commands/pogoda.py`, `console_commands/pogoda.py`
- **Проблема:** Цикл `for attempt in range(3)` не содержит `time.sleep()`. Все 3 попытки выполняются мгновенно, что бессмысленно при временных сетевых сбоях.
- **AGENTS.md обещает:** "Retry: 3 попытки с экспоненциальной задержкой" — но задержки нет.
- **Решение:** Добавить `time.sleep(2 ** attempt)` внутри цикла retry.
- **Статус:** Исправлено. Добавлена экспоненциальная задержка: 2 сек, 4 сек между попытками.
### 2. Блокировка event loop в async-методе ✅ РЕШЕНО
- **Где:** `commands/news.py`, `commands/cat.py`, `commands/pogoda.py`
- **Проблема:** Асинхронные методы вызывали синхронный `requests.get()`, блокируя весь event loop.
- **Решение:** Обернуть `requests.get()` в `await asyncio.to_thread()` и заменить `time.sleep()` на `await asyncio.sleep()`.
- **Статус:** Исправлено. В `news.py`, `cat.py`, `pogoda.py` все HTTP-запросы выполняются в отдельных потоках, а ожидания — неблокирующие.
---
## 🟡 Средние
### 3. Дублирование кода между Discord и console командами
- **Где:** `console_commands/pogoda.py` и `console_commands/news.py`
- **Проблема:** Логика погоды и новостей полностью продублирована. Изменения нужно вносить в два места.
- **Решение:** Вынести общую логику в `utils/` (например, `utils/weather.py`, `utils/rss.py`) и использовать её из обоих мест.
### 4. `from datetime import datetime` внутри метода
- **Где:** `commands/news.py``_format_and_send()`, строки 56 и 72
- **Проблема:** Импорт внутри метода — антипаттерн. Замедляет выполнение, нарушает PEP 8.
- **Решение:** Перенести `from datetime import datetime` в начало файла.
### 5. Консольная команда `cat` — заглушка
- **Где:** `console_commands/cat.py`
- **Проблема:** Функция просто печатает `"🐱 тут должен быть котик"`. Заглушка видна пользователю.
- **Решение:** Либо реализовать (вызвать API котиков), либо убрать из `ALL_CONSOLE_COMMANDS` и `README.md`.
### 6. `on_command_error` не сообщает пользователю об ошибке
- **Где:** `bot.py``on_command_error()`
- **Проблема:** Ошибки команд просто печатаются в `stdout`. Пользователь в чате не видит, что команда выполнилась с ошибкой.
- **Решение:** Добавить `await ctx.send("Произошла ошибка при выполнении команды.")` или отправить embed с деталями (если `ctx` не None).
### 7. Нет `.gitignore`
- **Где:** проект (файл отсутствует)
- **Проблема:** Нет явного `.gitignore`. `.env` может случайно попасть в репозиторий, хотя AGENTS.md говорит "`.env` в `.gitignore`".
- **Решение:** Создать `.gitignore` с правилами для Python (`__pycache__/`, `*.pyc`, `.env`, `venv/`, `*.egg-info/`).
### 8. `requests` без Session — нет переиспользования соединений
- **Где:** все файлы (`pogoda.py`, `news.py`)
- **Проблема:** Каждый `requests.get()` создаёт новое TCP-соединение. Для частых запросов это расточительно.
- **Решение:** Создать `requests.Session()` и использовать `session.get()`.
---
## 🟢 Малые улучшения
### 9. Linear search в `_translate_weather`
- **Где:** `commands/pogoda.py`, `console_commands/pogoda.py`
- **Проблема:** Метод `_translate_weather()` перебирает весь словарь `mapping` циклом `for key, value in mapping.items()`. При большом словаре — неэффективно.
- **Решение:** Сортировать ключи по длине (убывание) и использовать `re` или `any(key.lower() in text.lower() for key in sorted_keys)`.
### 10. Нет `__all__` в `__init__.py`
- **Где:** `commands/__init__.py`, `console_commands/__init__.py`
- **Проблема:** Нет явного экспорта публичного API модулей.
- **Решение:** Добавить `__all__ = ["ALL_COMMANDS"]` / `__all__ = ["ALL_CONSOLE_COMMANDS"]`.
### 11. Жёстко заданные URL в разных файлах
- **Где:** `commands/news.py` и `console_commands/news.py`
- **Проблема:** Константы `RSS_URL_ARTICLES` и `RSS_URL_POSTS` продублированы.
- **Решение:** Вынести в `config.py` и импортировать оттуда.
### 12. Потенциальная гонка при остановке бота
- **Где:** `bot.py`
- **Проблема:** `stop_event.set()` и `bot.close()` вызываются из `console_input()`, `KeyboardInterrupt` и `console_commands/stop.py`. Возможна гонка.
- **Решение:** Добавить флаг `is_stopping` или использовать `asyncio.Lock`.

View File

@ -1,5 +1,6 @@
import discord
from discord.ext import commands
import asyncio
import requests
@ -10,7 +11,8 @@ class Cat(commands.Cog):
async def cat(self, ctx):
"""Получить случайного котика"""
try:
response = requests.get(
response = await asyncio.to_thread(
requests.get,
"https://api.thecatapi.com/v1/images/search",
timeout=10
)

View File

@ -1,5 +1,6 @@
import discord
from discord.ext import commands
import asyncio
import requests
from xml.etree import ElementTree
@ -14,7 +15,7 @@ class News(commands.Cog):
@commands.command(name="news")
async def news(self, ctx):
"""Топ-5 свежих статей по AI с Habr"""
articles = self._fetch_rss(RSS_URL_ARTICLES)
articles = await self._fetch_rss(RSS_URL_ARTICLES)
if articles is None:
await ctx.send("Не удалось получить новости. Попробуйте позже.")
return
@ -25,10 +26,10 @@ class News(commands.Cog):
await self._format_and_send(ctx, articles)
def _fetch_rss(self, url):
async def _fetch_rss(self, url):
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
try:
response = requests.get(url, timeout=10)
response = await asyncio.to_thread(requests.get, url, timeout=10)
response.raise_for_status()
root = ElementTree.fromstring(response.content)
@ -99,7 +100,7 @@ class News(commands.Cog):
lines.append(f"{title}\n{date_str} <{article['link']}>")
# Второй блок: посты
posts = self._fetch_rss(RSS_URL_POSTS)
posts = await self._fetch_rss(RSS_URL_POSTS)
if posts:
lines.append("")
lines.append("**Лучшие новости за сутки / Искусственный интеллект / Хабr**\n<https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/>\n")

View File

@ -1,6 +1,7 @@
import discord
from discord.ext import commands
import requests
import asyncio
from requests.exceptions import ConnectionError, Timeout, SSLError
@ -47,11 +48,14 @@ class Pogoda(commands.Cog):
# Пробуем wttr.in с retry
for attempt in range(3):
try:
response = requests.get(self.api_url, timeout=10)
response = await asyncio.to_thread(requests.get, self.api_url, timeout=10)
response.raise_for_status()
return response.json()
except (SSLError, ConnectionError, Timeout):
if attempt < 2:
delay = 2 ** attempt
print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...")
await asyncio.sleep(delay)
continue
break
except requests.RequestException as e:
@ -71,7 +75,7 @@ class Pogoda(commands.Cog):
)
for attempt in range(3):
try:
response = requests.get(url, timeout=10)
response = await asyncio.to_thread(requests.get, url, timeout=10)
response.raise_for_status()
data = response.json()
current = data.get("current", {})
@ -90,6 +94,9 @@ class Pogoda(commands.Cog):
}
except (SSLError, ConnectionError, Timeout):
if attempt < 2:
delay = 2 ** attempt
print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...")
await asyncio.sleep(delay)
continue
break
except requests.RequestException as e:

View File

@ -1,4 +1,5 @@
import requests
import time
from requests.exceptions import ConnectionError, Timeout, SSLError
@ -45,6 +46,9 @@ def _fetch_weather(url):
return response.json()
except (SSLError, ConnectionError, Timeout):
if attempt < 2:
delay = 2 ** attempt
print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...")
time.sleep(delay)
continue
break
except requests.RequestException:
@ -83,6 +87,9 @@ def _fetch_open_meteo():
}
except (SSLError, ConnectionError, Timeout):
if attempt < 2:
delay = 2 ** attempt
print(f"Попытка {attempt + 1} не удалась. Повтор через {delay} сек...")
time.sleep(delay)
continue
break
except requests.RequestException: