feat: добавить rate-limiting для всех внешних API-вызовов
This commit is contained in:
parent
c6bde31539
commit
26666550ac
@ -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`** — добавить явную проверку с информативным сообщением
|
||||
|
||||
26
README.md
26
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 форматов
|
||||
- Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>`
|
||||
- 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) |
|
||||
|
||||
45
tests/test_rate_limiter.py
Normal file
45
tests/test_rate_limiter.py
Normal file
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,"
|
||||
|
||||
74
utils/rate_limiter.py
Normal file
74
utils/rate_limiter.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user