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`
|
- [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py`
|
||||||
- [ ] **Проверка наличия `.env`** — добавить явную проверку с информативным сообщением
|
- [ ] **Проверка наличия `.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()
|
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()
|
news.py # fetch_rss(), format_articles(), truncate_title()
|
||||||
cat.py # fetch_cat()
|
cat.py # fetch_cat()
|
||||||
|
rate_limiter.py # RateLimiter (токен-бакет), cat/weather/meteo/rss лимитеры
|
||||||
morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning()
|
morning_runner.py # Scheduler, MorningData, gather_morning(), run_morning()
|
||||||
tests/ # pytest-тесты
|
tests/ # pytest-тесты
|
||||||
test_pogoda.py # translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
|
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_morning_runner.py` | morning runner-а | 68 |
|
||||||
| `test_help_discord.py` | команда `!hp` | 2 |
|
| `test_help_discord.py` | команда `!hp` | 2 |
|
||||||
| `test_help_console.py` | консольная `help` | 2 |
|
| `test_help_console.py` | консольная `help` | 2 |
|
||||||
|
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
|
||||||
|
|
||||||
**Итого: 204 теста.**
|
**Итого: 209 тестов.**
|
||||||
|
|
||||||
## Запуск в Docker
|
## Запуск в Docker
|
||||||
|
|
||||||
@ -160,6 +162,7 @@ DISCORD_TOKEN=ваш_токен docker-compose up
|
|||||||
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
|
- **Fallback**: `api.open-meteo.com` (бесплатный, без ключа)
|
||||||
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
|
- Retry: 3 попытки с экспоненциальной задержкой при SSL/Connection/Timeout ошибках
|
||||||
- Fallback срабатывает автоматически при неуспешных попытках
|
- Fallback срабатывает автоматически при неуспешных попытках
|
||||||
|
- Rate-limiting: 1 req/sec, burst 3 (wttr.in); 2 req/sec, burst 5 (Open-Meteo). Настраивается через `.env`
|
||||||
- WMO weather codes → русский перевод в `wmo_to_russian()`
|
- 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`
|
- **News**: `https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru`
|
||||||
- Парсинг RSS 2.0 и Atom форматов
|
- Парсинг RSS 2.0 и Atom форматов
|
||||||
- Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>`
|
- Извлечение ссылок из `<guid isPermaLink="true">` и авторов из `<dc:creator>`
|
||||||
|
- Rate-limiting: 1 req/sec, burst 2. Настраивается через `.env`
|
||||||
- Формат вывода: заголовок → дата → ссылка
|
- Формат вывода: заголовок → дата → ссылка
|
||||||
|
|
||||||
### Котики (!cat, !morning)
|
### Котики (!cat, !morning)
|
||||||
- **API**: `https://api.thecatapi.com/v1/images/search`
|
- **API**: `https://api.thecatapi.com/v1/images/search`
|
||||||
|
- Rate-limiting: 1 req/sec, burst 3. Настраивается через `.env`
|
||||||
- Картинка встраивается в Discord Embed
|
- Картинка встраивается в Discord Embed
|
||||||
|
|
||||||
## Структура данных погоды
|
## Структура данных погоды
|
||||||
@ -201,6 +206,14 @@ DISCORD_TOKEN=ваш_токен docker-compose up
|
|||||||
| `DISCORD_TOKEN` | Токен бота | [Discord Developer Portal](https://discord.com/developers/applications) |
|
| `DISCORD_TOKEN` | Токен бота | [Discord Developer Portal](https://discord.com/developers/applications) |
|
||||||
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
|
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
|
||||||
| `MORNING_CHANNEL_ID` | ID канала для утреннего дайджеста | Правый клик по каналу → Копировать ID |
|
| `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()` | Параллельный сбор всех данных для дайджеста |
|
| `gather_morning()` | Параллельный сбор всех данных для дайджеста |
|
||||||
| `run_morning()` | Формирование и отправка embed в канал Discord |
|
| `run_morning()` | Формирование и отправка embed в канал Discord |
|
||||||
| `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) |
|
| `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
|
import requests
|
||||||
|
|
||||||
|
from utils.rate_limiter import cat_limiter
|
||||||
|
|
||||||
CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
|
CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
|
||||||
|
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
@ -9,6 +11,7 @@ _session = requests.Session()
|
|||||||
|
|
||||||
async def fetch_cat() -> str | None:
|
async def fetch_cat() -> str | None:
|
||||||
"""Получить URL случайного котика. Вернуть None при ошибке."""
|
"""Получить URL случайного котика. Вернуть None при ошибке."""
|
||||||
|
await cat_limiter.acquire()
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
|
response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@ -3,6 +3,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
import requests
|
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_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"
|
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):
|
async def fetch_rss(url):
|
||||||
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
||||||
|
await habr_rss_limiter.acquire()
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
|
|
||||||
|
from utils.rate_limiter import weather_limiter, open_meteo_limiter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
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):
|
async def fetch_weather(api_url, timeout=10, max_retries=3):
|
||||||
"""Получить данные о погоде с retry."""
|
"""Получить данные о погоде с retry."""
|
||||||
|
await weather_limiter.acquire()
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(_session.get, api_url, timeout=timeout)
|
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):
|
async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3):
|
||||||
"""Fallback на Open-Meteo API."""
|
"""Fallback на Open-Meteo API."""
|
||||||
|
await open_meteo_limiter.acquire()
|
||||||
url = (
|
url = (
|
||||||
f"https://api.open-meteo.com/v1/forecast?"
|
f"https://api.open-meteo.com/v1/forecast?"
|
||||||
f"latitude={lat}&longitude={lon}¤t=temperature,"
|
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