diff --git a/ISSUES.md b/ISSUES.md index 06f5ecd..74046ab 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -2,7 +2,7 @@ ## Средний приоритет -- [ ] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS) +- [x] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS) - [ ] **Настроить логирование** — уровни, формат, вывод в файл/консоль - [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py` - [ ] **Проверка наличия `.env`** — добавить явную проверку с информативным сообщением diff --git a/README.md b/README.md index 47907c3..157bcc4 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ utils/ # Утилиты (API-клиенты, конвертац pogoda.py # fetch_weather(), fetch_open_meteo(), wmo_to_russian(), translate_weather(), pressure_to_mmhg(), format_weather_data_for_console(), format_weather_for_embed() news.py # fetch_rss(), format_articles(), truncate_title() cat.py # fetch_cat() + rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning() tests/ # pytest-тесты test_pogoda.py # translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console @@ -129,8 +130,9 @@ python -m pytest tests/ -v | `test_morning_runner.py` | morning runner-а | 68 | | `test_help_discord.py` | команда `!hp` | 2 | | `test_help_console.py` | консольная `help` | 2 | +| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 | -**Итого: 204 теста.** +**Итого: 209 тестов.** ## Запуск в Docker @@ -160,6 +162,7 @@ DISCORD_TOKEN=ваш_токен docker-compose up - **Fallback**: `api.open-meteo.com` (бесплатный, без ключа) - Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках - Fallback срабатывает автоматически при неуспешных попытках +- Rate-limiting: 1 req/sec, burst 3 (wttr.in); 2 req/sec, burst 5 (Open-Meteo). Настраивается через `.env` - WMO weather codes → русский перевод в `wmo_to_russian()` ### Конвертации @@ -172,10 +175,12 @@ DISCORD_TOKEN=ваш_токен docker-compose up - **News**: `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru` - Парсинг RSS 2.0 и Atom форматов - Извлечение ссылок из `` и авторов из `` +- Rate-limiting: 1 req/sec, burst 2. Настраивается через `.env` - Формат вывода: заголовок → дата → ссылка ### Котики (!cat, !morning) - **API**: `https://api.thecatapi.com/v1/images/search` +- Rate-limiting: 1 req/sec, burst 3. Настраивается через `.env` - Картинка встраивается в Discord Embed ## Структура данных погоды @@ -201,6 +206,14 @@ DISCORD_TOKEN=ваш_токен docker-compose up | `DISCORD_TOKEN` | Токен бота | [Discord Developer Portal](https://discord.com/developers/applications) | | `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) | | `MORNING_CHANNEL_ID` | ID канала для утреннего дайджеста | Правый клик по каналу → Копировать ID | +| `CAT_API_RATE` | Rate-limit TheCatAPI (токенов/сек) | `.env`, по умолчанию `1` | +| `CAT_API_BURST` | Burst-бакет TheCatAPI | `.env`, по умолчанию `3` | +| `WEATHER_API_RATE` | Rate-limit wttr.in (токенов/сек) | `.env`, по умолчанию `1` | +| `WEATHER_API_BURST` | Burst-бакет wttr.in | `.env`, по умолчанию `3` | +| `OPEN_METEO_API_RATE` | Rate-limit Open-Meteo (токенов/сек) | `.env`, по умолчанию `2` | +| `OPEN_METEO_API_BURST` | Burst-бакет Open-Meteo | `.env`, по умолчанию `5` | +| `HABR_RSS_RATE` | Rate-limit Habr RSS (токенов/сек) | `.env`, по умолчанию `1` | +| `HABR_RSS_BURST` | Burst-бакет Habr RSS | `.env`, по умолчанию `2` | ## Зависимости @@ -266,3 +279,14 @@ pytest-asyncio>=0.21.0 | `gather_morning()` | Параллельный сбор всех данных для дайджеста | | `run_morning()` | Формирование и отправка embed в канал Discord | | `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) | + +### utils/rate_limiter.py + +| Функция / Класс | Описание | +|----------------|----------| +| `RateLimiter` | Токен-бакет: `rate` (токенов/сек), `burst` (макс. бакет) | +| `RateLimiter.acquire()` | Асинхронно ждать освобождения токена перед запросом | +| `cat_limiter` | Лимитер для TheCatAPI (1/s, burst 3) | +| `weather_limiter` | Лимитер для wttr.in (1/s, burst 3) | +| `open_meteo_limiter` | Лимитер для Open-Meteo (2/s, burst 5) | +| `habr_rss_limiter` | Лимитер для Habr RSS (1/s, burst 2) | diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py new file mode 100644 index 0000000..dc0e58e --- /dev/null +++ b/tests/test_rate_limiter.py @@ -0,0 +1,45 @@ +"""Тесты для utils/rate_limiter.py — проверка логики токен-бакета.""" + +import asyncio +import time + +from utils.rate_limiter import RateLimiter + + +async def test_initial_tokens_full() -> None: + """Бакет заполнен до burst при создании.""" + limiter = RateLimiter(rate=2.0, burst=5) + assert limiter.tokens == 5.0 + + +async def test_acquire_consumes_token() -> None: + """acquire() уменьшает количество токенов.""" + limiter = RateLimiter(rate=1.0, burst=3) + await limiter.acquire() + assert limiter.tokens == 2.0 + + +async def test_acquire_waits_when_empty() -> None: + """acquire() ждёт, когда токены закончились.""" + limiter = RateLimiter(rate=10.0, burst=1) # 10 токенов/сек + await limiter.acquire() # бакет пуст + start = time.monotonic() + await limiter.acquire() # должен ждать ~0.1 сек + elapsed = time.monotonic() - start + assert elapsed >= 0.05 # допускаем погрешность + + +async def test_burst_cap() -> None: + """Токены не превышают burst после долгого простоя.""" + limiter = RateLimiter(rate=100.0, burst=3) + await asyncio.sleep(0.1) # теоретически +10 токенов, но cap = 3 + async with limiter.lock: + limiter._refill() + assert limiter.tokens == 3.0 + + +async def test_multiple_acquire() -> None: + """Можно забрать несколько токенов за раз.""" + limiter = RateLimiter(rate=1.0, burst=10) + await limiter.acquire(token=5) + assert limiter.tokens == 5.0 diff --git a/utils/cat.py b/utils/cat.py index b9dac52..4414b10 100644 --- a/utils/cat.py +++ b/utils/cat.py @@ -2,6 +2,8 @@ import asyncio import requests +from utils.rate_limiter import cat_limiter + CAT_API_URL = "https://api.thecatapi.com/v1/images/search" _session = requests.Session() @@ -9,6 +11,7 @@ _session = requests.Session() async def fetch_cat() -> str | None: """Получить URL случайного котика. Вернуть None при ошибке.""" + await cat_limiter.acquire() try: response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10) response.raise_for_status() diff --git a/utils/news.py b/utils/news.py index 1b9f554..921fd97 100644 --- a/utils/news.py +++ b/utils/news.py @@ -3,6 +3,8 @@ from datetime import datetime import requests +from utils.rate_limiter import habr_rss_limiter + RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru" RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru" @@ -11,6 +13,7 @@ _session = requests.Session() async def fetch_rss(url): """Скачать и распарсить RSS-ленту (RSS 2.0 / Atom).""" + await habr_rss_limiter.acquire() from xml.etree import ElementTree try: diff --git a/utils/pogoda.py b/utils/pogoda.py index 2f9eef9..a3480fe 100644 --- a/utils/pogoda.py +++ b/utils/pogoda.py @@ -3,6 +3,8 @@ import logging import requests from requests.exceptions import ConnectionError, Timeout, SSLError +from utils.rate_limiter import weather_limiter, open_meteo_limiter + logger = logging.getLogger(__name__) API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" @@ -12,6 +14,7 @@ _session = requests.Session() async def fetch_weather(api_url, timeout=10, max_retries=3): """Получить данные о погоде с retry.""" + await weather_limiter.acquire() for attempt in range(max_retries): try: response = await asyncio.to_thread(_session.get, api_url, timeout=timeout) @@ -34,6 +37,7 @@ async def fetch_weather(api_url, timeout=10, max_retries=3): async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3): """Fallback на Open-Meteo API.""" + await open_meteo_limiter.acquire() url = ( f"https://api.open-meteo.com/v1/forecast?" f"latitude={lat}&longitude={lon}¤t=temperature," diff --git a/utils/rate_limiter.py b/utils/rate_limiter.py new file mode 100644 index 0000000..e69d859 --- /dev/null +++ b/utils/rate_limiter.py @@ -0,0 +1,74 @@ +""" +Rate-limiter на основе токен-бакета для внешних API-вызовов. + +Каждый API получает свой RateLimiter с настройками rate (токенов/сек) +и burst (максимальный размер бакета). Перед каждым запросом вызывается +async acquire(), который ждёт освобождения токена при необходимости. +""" + +import asyncio +import logging +import os +import time +from typing import Final + +logger = logging.getLogger(__name__) + + +class RateLimiter: + """Токен-бакет: заполняется со скоростью rate токенов/сек, максимум burst.""" + + def __init__(self, rate: float, burst: int) -> None: + """ + Args: + rate: Скорость пополнения токенов (токенов в секунду). + burst: Максимальный размер бакета. + """ + self.rate: float = rate + self.burst: int = burst + self.tokens: float = float(burst) + self.lock: asyncio.Lock = asyncio.Lock() + self._last_refill: float = time.monotonic() + + def _refill(self) -> None: + """Пополнить токены за прошедшее время.""" + now: float = time.monotonic() + elapsed: float = now - self._last_refill + self.tokens = min(self.burst, self.tokens + elapsed * self.rate) + self._last_refill = now + + async def acquire(self, token: int = 1) -> None: + """Забрать token токенов, ждать если их нет.""" + while True: + async with self.lock: + self._refill() + if self.tokens >= token: + self.tokens -= token + return + # Ждём достаточно времени для восстановления 1 токена + await asyncio.sleep(token / self.rate) + + +# --- Готовые лимитеры по API --- + +# TheCatAPI: бесплатно, 1 req/sec, burst 3 +_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1")) +_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3")) + +# wttr.in: без ключа, 1 req/sec, burst 3 +_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1")) +_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3")) + +# Open-Meteo: fallback, 2 req/sec, burst 5 +_OPEN_METEO_RATE: Final[float] = float(os.getenv("OPEN_METEO_API_RATE", "2")) +_OPEN_METEO_BURST: Final[int] = int(os.getenv("OPEN_METEO_API_BURST", "5")) + +# Habr RSS: 1 req/sec, burst 2 +_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1")) +_HABR_RSS_BURST: Final[int] = int(os.getenv("HABR_RSS_BURST", "2")) + +# Экземпляры лимитеров +cat_limiter: RateLimiter = RateLimiter(_CAT_RATE, _CAT_BURST) +weather_limiter: RateLimiter = RateLimiter(_WEATHER_RATE, _WEATHER_BURST) +open_meteo_limiter: RateLimiter = RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST) +habr_rss_limiter: RateLimiter = RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)