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:
deadzilla 2026-07-21 00:08:09 +05:00
parent b8b1801f23
commit f7df0f4900
3 changed files with 184 additions and 4 deletions

View File

@ -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

View File

@ -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",

View File

@ -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