feat(weather): кэширование погоды с TTL 1 час
- fetch_weather кэширует результат на 3600 сек (WEATHER_CACHE_TTL) - Провал запроса тоже кэшируется — не долбит API повторно - bypass_cache=True для форсирования свежего запроса - clear_weather_cache() для сброса кэша - asyncio.Lock для thread-safety - 5 новых тестов (cache hit, bypass, failure cache, clear, TTL expiry)
This commit is contained in:
parent
b8b1801f23
commit
f7df0f4900
@ -2,12 +2,17 @@ import pytest
|
|||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.pogoda import fetch_weather
|
from utils.pogoda import fetch_weather, clear_weather_cache
|
||||||
|
|
||||||
|
|
||||||
class TestFetchWeather:
|
class TestFetchWeather:
|
||||||
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
|
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache(self) -> None:
|
||||||
|
"""Очистить кэш перед каждым тестом."""
|
||||||
|
clear_weather_cache()
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _mock_api_key(self) -> None:
|
def _mock_api_key(self) -> None:
|
||||||
"""Мокаем _get_api_key для всех тестов в классе."""
|
"""Мокаем _get_api_key для всех тестов в классе."""
|
||||||
@ -299,3 +304,143 @@ class TestFetchWeather:
|
|||||||
result = await fetch_weather(max_retries=1)
|
result = await fetch_weather(max_retries=1)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeatherCache:
|
||||||
|
"""Тесты кэширования погоды."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache(self) -> None:
|
||||||
|
clear_weather_cache()
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _mock_api_key(self) -> None:
|
||||||
|
with patch("utils.pogoda._get_api_key", return_value="test-key"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_cache_returns_cached_data(self, mock_get) -> None:
|
||||||
|
"""Второй запрос в течение TTL возвращает кэш без вызова API."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
result1 = await fetch_weather()
|
||||||
|
result2 = await fetch_weather()
|
||||||
|
|
||||||
|
assert result1 is not None
|
||||||
|
assert result2 is not None
|
||||||
|
assert result1 == result2
|
||||||
|
# API вызван только один раз
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_bypass_cache_forces_new_request(self, mock_get) -> None:
|
||||||
|
"""bypass_cache=True делает новый запрос к API."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
await fetch_weather()
|
||||||
|
await fetch_weather(bypass_cache=True)
|
||||||
|
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_cache_stores_failure(self, mock_get) -> None:
|
||||||
|
"""Провал запроса кэшируется — повторный вызов не долбит API."""
|
||||||
|
mock_get.side_effect = ConnectionError("fail")
|
||||||
|
|
||||||
|
result1 = await fetch_weather(max_retries=1)
|
||||||
|
result2 = await fetch_weather()
|
||||||
|
|
||||||
|
assert result1 is None
|
||||||
|
assert result2 is None # из кэша
|
||||||
|
# API вызван только один раз (первый раз)
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_clear_cache_invalidates(self, mock_get) -> None:
|
||||||
|
"""clear_weather_cache сбрасывает кэш."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
await fetch_weather()
|
||||||
|
clear_weather_cache()
|
||||||
|
await fetch_weather()
|
||||||
|
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
|
@patch("utils.pogoda._session.get")
|
||||||
|
async def test_cache_ttl_expiry(self, mock_get) -> None:
|
||||||
|
"""После истечения TTL кэш инвалидируется."""
|
||||||
|
import utils.pogoda as pogoda_module
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"fact": {
|
||||||
|
"temp": 20,
|
||||||
|
"feels_like": 18,
|
||||||
|
"condition": "clear",
|
||||||
|
"wind_speed": 3,
|
||||||
|
"wind_gust": 5,
|
||||||
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Устанавливаем TTL = 0, чтобы кэш сразу истёк
|
||||||
|
original_ttl = pogoda_module._WEATHER_CACHE_TTL
|
||||||
|
pogoda_module._WEATHER_CACHE_TTL = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
await fetch_weather()
|
||||||
|
await fetch_weather()
|
||||||
|
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
finally:
|
||||||
|
pogoda_module._WEATHER_CACHE_TTL = original_ttl
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from .pogoda import (
|
from .pogoda import (
|
||||||
_session as _weather_session,
|
_session as _weather_session,
|
||||||
|
clear_weather_cache,
|
||||||
fetch_weather,
|
fetch_weather,
|
||||||
format_weather_data_for_console,
|
format_weather_data_for_console,
|
||||||
format_weather_for_message,
|
format_weather_for_message,
|
||||||
@ -22,6 +23,7 @@ from .cat import ( # noqa: E402
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Погода
|
# Погода
|
||||||
|
"clear_weather_cache",
|
||||||
"fetch_weather",
|
"fetch_weather",
|
||||||
"format_weather_data_for_console",
|
"format_weather_data_for_console",
|
||||||
"format_weather_for_message",
|
"format_weather_for_message",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -25,6 +26,12 @@ _LONGITUDE: float = 58.980289
|
|||||||
|
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
|
# Кэш погоды: (data, timestamp) или (None, timestamp) при провале запроса
|
||||||
|
_weather_cache: tuple[Optional[dict], float] = (None, 0.0)
|
||||||
|
_weather_cache_lock = asyncio.Lock()
|
||||||
|
# TTL в секундах — по умолчанию 1 час, переопределяется WEATHER_CACHE_TTL
|
||||||
|
_WEATHER_CACHE_TTL: float = float(os.getenv("WEATHER_CACHE_TTL", "3600"))
|
||||||
|
|
||||||
|
|
||||||
def _get_api_key() -> str:
|
def _get_api_key() -> str:
|
||||||
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
|
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
|
||||||
@ -36,17 +43,37 @@ def _get_api_key() -> str:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def clear_weather_cache() -> None:
|
||||||
|
"""Очистить кэш погоды. Используется в тестах."""
|
||||||
|
global _weather_cache
|
||||||
|
_weather_cache = (None, 0.0)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_weather(
|
async def fetch_weather(
|
||||||
lat: float = _LATITUDE,
|
lat: float = _LATITUDE,
|
||||||
lon: float = _LONGITUDE,
|
lon: float = _LONGITUDE,
|
||||||
timeout: int = 10,
|
timeout: int = 10,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
|
bypass_cache: bool = False,
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Получить текущую погоду через Яндекс Погоду API.
|
"""Получить текущую погоду через Яндекс Погоду API.
|
||||||
|
|
||||||
Возвращает dict в унифицированном формате для форматирования:
|
Результат кэшируется на _WEATHER_CACHE_TTL секунд (по умолчанию 1 ч).
|
||||||
{"current_condition": [{"temp_C": ..., "weatherDesc": ..., ...}]}
|
Повторные вызовы в течение TTL возвращают кэшированные данные без запроса к API.
|
||||||
|
|
||||||
|
:param bypass_cache: Пропустить кэш и сделать свежий запрос.
|
||||||
|
:return: dict в унифицированном формате или None при ошибке.
|
||||||
"""
|
"""
|
||||||
|
global _weather_cache
|
||||||
|
|
||||||
|
# Проверка кэша
|
||||||
|
if not bypass_cache:
|
||||||
|
async with _weather_cache_lock:
|
||||||
|
cached_data, cached_time = _weather_cache
|
||||||
|
if time.monotonic() - cached_time < _WEATHER_CACHE_TTL:
|
||||||
|
logger.debug("Погода: возвращены данные из кэша (TTL %.0f сек)", _WEATHER_CACHE_TTL)
|
||||||
|
return cached_data
|
||||||
|
|
||||||
await yandex_weather_limiter.acquire()
|
await yandex_weather_limiter.acquire()
|
||||||
url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}"
|
url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}"
|
||||||
headers = {"X-Yandex-API-Key": _get_api_key()}
|
headers = {"X-Yandex-API-Key": _get_api_key()}
|
||||||
@ -60,7 +87,7 @@ async def fetch_weather(
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
fact = data.get("fact", {})
|
fact = data.get("fact", {})
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": fact.get("temp"),
|
"temp_C": fact.get("temp"),
|
||||||
@ -75,6 +102,9 @@ async def fetch_weather(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
async with _weather_cache_lock:
|
||||||
|
_weather_cache = (result, time.monotonic())
|
||||||
|
return result
|
||||||
except (SSLError, ConnectionError, Timeout):
|
except (SSLError, ConnectionError, Timeout):
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = 2**attempt
|
delay = 2**attempt
|
||||||
@ -89,6 +119,9 @@ async def fetch_weather(
|
|||||||
break
|
break
|
||||||
|
|
||||||
logger.warning("Все попытки Яндекс Погоды не удались")
|
logger.warning("Все попытки Яндекс Погоды не удались")
|
||||||
|
# Сохраняем провал в кэш, чтобы не долбить API повторно
|
||||||
|
async with _weather_cache_lock:
|
||||||
|
_weather_cache = (None, time.monotonic())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user