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
|
||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||
from unittest.mock import patch, MagicMock
|
||||
from utils.pogoda import fetch_weather
|
||||
from utils.pogoda import fetch_weather, clear_weather_cache
|
||||
|
||||
|
||||
class TestFetchWeather:
|
||||
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache(self) -> None:
|
||||
"""Очистить кэш перед каждым тестом."""
|
||||
clear_weather_cache()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_api_key(self) -> None:
|
||||
"""Мокаем _get_api_key для всех тестов в классе."""
|
||||
@ -299,3 +304,143 @@ class TestFetchWeather:
|
||||
result = await fetch_weather(max_retries=1)
|
||||
|
||||
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 (
|
||||
_session as _weather_session,
|
||||
clear_weather_cache,
|
||||
fetch_weather,
|
||||
format_weather_data_for_console,
|
||||
format_weather_for_message,
|
||||
@ -22,6 +23,7 @@ from .cat import ( # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
# Погода
|
||||
"clear_weather_cache",
|
||||
"fetch_weather",
|
||||
"format_weather_data_for_console",
|
||||
"format_weather_for_message",
|
||||
|
||||
@ -10,6 +10,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
@ -25,6 +26,12 @@ _LONGITUDE: float = 58.980289
|
||||
|
||||
_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:
|
||||
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
|
||||
@ -36,17 +43,37 @@ def _get_api_key() -> str:
|
||||
return key
|
||||
|
||||
|
||||
def clear_weather_cache() -> None:
|
||||
"""Очистить кэш погоды. Используется в тестах."""
|
||||
global _weather_cache
|
||||
_weather_cache = (None, 0.0)
|
||||
|
||||
|
||||
async def fetch_weather(
|
||||
lat: float = _LATITUDE,
|
||||
lon: float = _LONGITUDE,
|
||||
timeout: int = 10,
|
||||
max_retries: int = 3,
|
||||
bypass_cache: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""Получить текущую погоду через Яндекс Погоду API.
|
||||
|
||||
Возвращает dict в унифицированном формате для форматирования:
|
||||
{"current_condition": [{"temp_C": ..., "weatherDesc": ..., ...}]}
|
||||
Результат кэшируется на _WEATHER_CACHE_TTL секунд (по умолчанию 1 ч).
|
||||
Повторные вызовы в течение 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()
|
||||
url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}"
|
||||
headers = {"X-Yandex-API-Key": _get_api_key()}
|
||||
@ -60,7 +87,7 @@ async def fetch_weather(
|
||||
data = response.json()
|
||||
fact = data.get("fact", {})
|
||||
|
||||
return {
|
||||
result = {
|
||||
"current_condition": [
|
||||
{
|
||||
"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):
|
||||
if attempt < max_retries - 1:
|
||||
delay = 2**attempt
|
||||
@ -89,6 +119,9 @@ async def fetch_weather(
|
||||
break
|
||||
|
||||
logger.warning("Все попытки Яндекс Погоды не удались")
|
||||
# Сохраняем провал в кэш, чтобы не долбить API повторно
|
||||
async with _weather_cache_lock:
|
||||
_weather_cache = (None, time.monotonic())
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user