Harden Open-Meteo rate limiting
This commit is contained in:
+9
-12
@@ -13,6 +13,7 @@ POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
|
||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||
OPEN_METEO_DISK_CACHE_PATH=/var/lib/polyweather/open_meteo_cache.json
|
||||
UVICORN_WORKERS=1
|
||||
# Optional: host user/group mapping for Docker on Linux.
|
||||
# Windows / macOS can usually keep the defaults.
|
||||
UID=1000
|
||||
@@ -40,13 +41,16 @@ TELEGRAM_AIRPORT_PUSH_MAX_WORKERS=1
|
||||
########################################
|
||||
# 3) Weather + cache
|
||||
########################################
|
||||
OPEN_METEO_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=7200
|
||||
OPEN_METEO_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC=21600
|
||||
OPEN_METEO_MULTI_MODEL_CACHE_VERSION=v2
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=900
|
||||
OPEN_METEO_RATE_LIMIT_COOLDOWN_SEC=3600
|
||||
OPEN_METEO_RATE_CACHE_TTL_SEC=3600
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=1
|
||||
OPEN_METEO_MIN_CALL_INTERVAL_SEC=5
|
||||
POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS=1
|
||||
POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC=600
|
||||
POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC=45
|
||||
POLYWEATHER_HTTP_TIMEOUT_SEC=8
|
||||
POLYWEATHER_HTTP_RETRY_COUNT=0
|
||||
POLYWEATHER_HTTP_RETRY_BACKOFF_SEC=0.2
|
||||
@@ -113,13 +117,6 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
||||
# 7) Optional modules
|
||||
########################################
|
||||
|
||||
# Optional Groq commentary rewrite for intraday structure cards
|
||||
POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
GROQ_API_KEY=
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
|
||||
# Optional OpenAI-compatible market scan review for Pro users
|
||||
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
|
||||
@@ -351,8 +351,9 @@ class NwsOpenMeteoSourceMixin:
|
||||
now_ts = time.time()
|
||||
# ── 429 冷却期检查(所有 Open-Meteo 端点共享)─────────────────
|
||||
with self._open_meteo_rl_lock:
|
||||
if now_ts < self._open_meteo_rate_limit_until:
|
||||
remaining = int(self._open_meteo_rate_limit_until - now_ts)
|
||||
cooldown_until = self._refresh_open_meteo_rate_limit_until()
|
||||
if now_ts < cooldown_until:
|
||||
remaining = int(cooldown_until - now_ts)
|
||||
logger.debug(f"Open-Meteo 冷却期中,跳过请求,还需 {remaining}s")
|
||||
with self._open_meteo_cache_lock:
|
||||
stale = self._open_meteo_cache.get(cache_key)
|
||||
@@ -494,7 +495,7 @@ class NwsOpenMeteoSourceMixin:
|
||||
)
|
||||
# 设置全局冷却期,避免短时内重复触发 429
|
||||
with self._open_meteo_rl_lock:
|
||||
self._open_meteo_rate_limit_until = time.time() + cooldown_to_use
|
||||
self._set_open_meteo_rate_limit_until(time.time() + cooldown_to_use, reason="forecast_429")
|
||||
logger.warning(f"Open-Meteo 触发限流,设置 {cooldown_to_use}s 冷却期")
|
||||
else:
|
||||
logger.error(f"Open-Meteo forecast failed: {e}")
|
||||
@@ -527,8 +528,9 @@ class NwsOpenMeteoSourceMixin:
|
||||
now_ts = time.time()
|
||||
# ── 429 冷却期检查(所有 Open-Meteo 端点共享)─────────────────
|
||||
with self._open_meteo_rl_lock:
|
||||
if now_ts < self._open_meteo_rate_limit_until:
|
||||
remaining = int(self._open_meteo_rate_limit_until - now_ts)
|
||||
cooldown_until = self._refresh_open_meteo_rate_limit_until()
|
||||
if now_ts < cooldown_until:
|
||||
remaining = int(cooldown_until - now_ts)
|
||||
logger.debug(f"Open-Meteo Ensemble 冷却期中,跳过请求,还需 {remaining}s")
|
||||
with self._ensemble_cache_lock:
|
||||
stale = self._ensemble_cache.get(cache_key)
|
||||
@@ -647,7 +649,7 @@ class NwsOpenMeteoSourceMixin:
|
||||
f"Ensemble API rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
|
||||
)
|
||||
with self._open_meteo_rl_lock:
|
||||
self._open_meteo_rate_limit_until = time.time() + cooldown_to_use
|
||||
self._set_open_meteo_rate_limit_until(time.time() + cooldown_to_use, reason="ensemble_429")
|
||||
else:
|
||||
logger.warning(f"Ensemble API 请求失败: {e}")
|
||||
with self._ensemble_cache_lock:
|
||||
@@ -690,8 +692,9 @@ class NwsOpenMeteoSourceMixin:
|
||||
now_ts = time.time()
|
||||
# ── 429 冷却期检查(所有 Open-Meteo 端点共享)─────────────────
|
||||
with self._open_meteo_rl_lock:
|
||||
if now_ts < self._open_meteo_rate_limit_until:
|
||||
remaining = int(self._open_meteo_rate_limit_until - now_ts)
|
||||
cooldown_until = self._refresh_open_meteo_rate_limit_until()
|
||||
if now_ts < cooldown_until:
|
||||
remaining = int(cooldown_until - now_ts)
|
||||
logger.debug(f"Open-Meteo Multi-model 冷却期中,跳过请求,还需 {remaining}s")
|
||||
with self._multi_model_cache_lock:
|
||||
stale = self._multi_model_cache.get(cache_key)
|
||||
@@ -808,7 +811,7 @@ class NwsOpenMeteoSourceMixin:
|
||||
f"Multi-model API rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
|
||||
)
|
||||
with self._open_meteo_rl_lock:
|
||||
self._open_meteo_rate_limit_until = time.time() + cooldown_to_use
|
||||
self._set_open_meteo_rate_limit_until(time.time() + cooldown_to_use, reason="multi_model_429")
|
||||
else:
|
||||
logger.warning(f"Multi-model API 请求失败: {e}")
|
||||
with self._multi_model_cache_lock:
|
||||
|
||||
@@ -7,11 +7,13 @@ import time
|
||||
from loguru import logger
|
||||
from src.database.runtime_state import (
|
||||
OpenMeteoCacheRepository,
|
||||
OpenMeteoRateLimitRepository,
|
||||
STATE_STORAGE_SQLITE,
|
||||
get_state_storage_mode,
|
||||
)
|
||||
|
||||
_open_meteo_cache_repo = OpenMeteoCacheRepository()
|
||||
_open_meteo_rate_limit_repo = OpenMeteoRateLimitRepository()
|
||||
|
||||
|
||||
class OpenMeteoCacheMixin:
|
||||
@@ -182,3 +184,26 @@ class OpenMeteoCacheMixin:
|
||||
time.sleep(wait_for)
|
||||
now_ts = time.time()
|
||||
self._open_meteo_last_call_ts = now_ts
|
||||
|
||||
def _refresh_open_meteo_rate_limit_until(self) -> float:
|
||||
"""Load the cross-process Open-Meteo cooldown marker from runtime state."""
|
||||
if get_state_storage_mode() != STATE_STORAGE_SQLITE:
|
||||
return getattr(self, "_open_meteo_rate_limit_until", 0.0)
|
||||
try:
|
||||
persisted_until = _open_meteo_rate_limit_repo.load_until()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Open-Meteo cooldown load skipped: {exc}")
|
||||
return getattr(self, "_open_meteo_rate_limit_until", 0.0)
|
||||
if persisted_until > getattr(self, "_open_meteo_rate_limit_until", 0.0):
|
||||
self._open_meteo_rate_limit_until = persisted_until
|
||||
return self._open_meteo_rate_limit_until
|
||||
|
||||
def _set_open_meteo_rate_limit_until(self, rate_limit_until: float, *, reason: str = "") -> None:
|
||||
"""Persist the shared Open-Meteo cooldown marker for all workers."""
|
||||
self._open_meteo_rate_limit_until = float(rate_limit_until)
|
||||
if get_state_storage_mode() != STATE_STORAGE_SQLITE:
|
||||
return
|
||||
try:
|
||||
_open_meteo_rate_limit_repo.set_until(rate_limit_until, reason=reason)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Open-Meteo cooldown persist skipped: {exc}")
|
||||
|
||||
@@ -1316,7 +1316,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
keep_model_caches=force_refresh_observations_only,
|
||||
# Force-refresh is usually a UI/API freshness request for live
|
||||
# observations. Do not evict longer-lived Open-Meteo forecast
|
||||
# caches by default: one accidental all-city refresh can
|
||||
# otherwise cold-start the VPS into Open-Meteo 429s.
|
||||
keep_model_caches=True,
|
||||
)
|
||||
self._log_temperature_unit(city, use_fahrenheit)
|
||||
self._attach_settlement_sources(results, city_lower)
|
||||
|
||||
@@ -193,6 +193,16 @@ class RuntimeStateDB:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_open_meteo_cache_expires ON open_meteo_cache_store(source_kind, expires_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS open_meteo_rate_limit_state (
|
||||
state_key TEXT PRIMARY KEY,
|
||||
rate_limit_until REAL NOT NULL,
|
||||
reason TEXT,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS official_intraday_observations_store (
|
||||
@@ -1001,6 +1011,43 @@ class OpenMeteoCacheRepository:
|
||||
return 0.0
|
||||
|
||||
|
||||
class OpenMeteoRateLimitRepository:
|
||||
def __init__(self, db: Optional[RuntimeStateDB] = None):
|
||||
self.db = db or RuntimeStateDB.instance()
|
||||
|
||||
def set_until(self, rate_limit_until: float, *, reason: str = "") -> None:
|
||||
with self.db.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO open_meteo_rate_limit_state (
|
||||
state_key, rate_limit_until, reason, updated_at
|
||||
) VALUES ('global', ?, ?, ?)
|
||||
ON CONFLICT(state_key) DO UPDATE SET
|
||||
rate_limit_until = excluded.rate_limit_until,
|
||||
reason = excluded.reason,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(float(rate_limit_until), str(reason or ""), time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_until(self) -> float:
|
||||
with self.db.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT rate_limit_until
|
||||
FROM open_meteo_rate_limit_state
|
||||
WHERE state_key = 'global'
|
||||
"""
|
||||
).fetchone()
|
||||
if not row:
|
||||
return 0.0
|
||||
try:
|
||||
return float(row["rate_limit_until"] or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
class OfficialIntradayObservationRepository:
|
||||
def __init__(self, db: Optional[RuntimeStateDB] = None):
|
||||
self.db = db or RuntimeStateDB.instance()
|
||||
|
||||
@@ -2,7 +2,9 @@ from src.data_collection.nws_open_meteo_sources import (
|
||||
OPEN_METEO_MULTI_MODEL_ORDER,
|
||||
_parse_open_meteo_multi_model_daily,
|
||||
)
|
||||
import src.data_collection.open_meteo_cache as open_meteo_cache_module
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.database.runtime_state import OpenMeteoRateLimitRepository, RuntimeStateDB
|
||||
|
||||
|
||||
def test_multi_model_parser_exposes_open_recommended_models():
|
||||
@@ -94,3 +96,67 @@ def test_fetch_all_sources_prioritizes_multi_model_before_forecast(monkeypatch,
|
||||
|
||||
assert calls[:2] == ["multi_model", "open_meteo"]
|
||||
assert result["multi_model"]["forecasts"]["ECMWF"] == 24.0
|
||||
|
||||
|
||||
def test_force_refresh_preserves_open_meteo_model_caches_by_default(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
||||
collector = WeatherDataCollector({})
|
||||
captured = {}
|
||||
|
||||
def fake_evict(*args, **kwargs):
|
||||
captured["keep_model_caches"] = kwargs.get("keep_model_caches")
|
||||
|
||||
monkeypatch.setattr(collector, "_evict_city_caches", fake_evict)
|
||||
monkeypatch.setattr(collector, "_log_temperature_unit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_settlement_sources", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_supports_aviationweather", lambda city: False)
|
||||
monkeypatch.setattr(collector, "fetch_multi_model", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "fetch_from_open_meteo", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_nws_and_models", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_turkish_mgm_data", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_korean_amos_data", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_china_amsc_awos_data", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_madis_hfmetar_data", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_singapore_mss_data", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_china_official_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_japan_official_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_fmi_official_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_knmi_official_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_hko_obs_official_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_cwa_settlement_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_russia_official_nearby", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(collector, "_attach_global_nearby_cluster", lambda *args, **kwargs: None)
|
||||
|
||||
collector.fetch_all_sources(
|
||||
"ankara",
|
||||
lat=40.1281,
|
||||
lon=32.9951,
|
||||
force_refresh=True,
|
||||
include_ensemble=False,
|
||||
include_nearby=False,
|
||||
include_taf=False,
|
||||
include_mgm=False,
|
||||
)
|
||||
|
||||
assert captured["keep_model_caches"] is True
|
||||
|
||||
|
||||
def test_persisted_open_meteo_cooldown_skips_outbound_request(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "sqlite")
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "polyweather.db"))
|
||||
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
||||
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||
rate_repo = OpenMeteoRateLimitRepository(db)
|
||||
rate_repo.set_until(9999999999.0, reason="test_429")
|
||||
monkeypatch.setattr(open_meteo_cache_module, "_open_meteo_rate_limit_repo", rate_repo)
|
||||
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
def fail_http(*args, **kwargs):
|
||||
raise AssertionError("Open-Meteo HTTP request should be skipped during persisted cooldown")
|
||||
|
||||
monkeypatch.setattr(collector, "_http_get", fail_http)
|
||||
|
||||
result = collector.fetch_multi_model(40.1281, 32.9951, city="ankara")
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from src.database.runtime_state import (
|
||||
DailyRecordRepository,
|
||||
OpenMeteoCacheRepository,
|
||||
OpenMeteoRateLimitRepository,
|
||||
ProbabilitySnapshotRepository,
|
||||
RuntimeStateDB,
|
||||
TelegramAlertStateRepository,
|
||||
@@ -77,6 +78,16 @@ def test_open_meteo_cache_repository_roundtrip(tmp_path, monkeypatch):
|
||||
assert loaded['ensemble']['ankara']['spread'] == 1.5
|
||||
|
||||
|
||||
def test_open_meteo_rate_limit_repository_roundtrip(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
|
||||
repo = OpenMeteoRateLimitRepository(RuntimeStateDB(str(tmp_path / 'polyweather.db')))
|
||||
|
||||
repo.set_until(12345.0, reason='429')
|
||||
|
||||
loaded = repo.load_until()
|
||||
assert loaded == 12345.0
|
||||
|
||||
|
||||
def test_truth_record_repository_tracks_revisions(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('POLYWEATHER_DB_PATH', str(tmp_path / 'polyweather.db'))
|
||||
db = RuntimeStateDB(str(tmp_path / 'polyweather.db'))
|
||||
|
||||
+3
-1
@@ -39,5 +39,7 @@ if __name__ == "__main__":
|
||||
"web.app:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
workers=int(os.getenv("UVICORN_WORKERS", "4")),
|
||||
# Default to a single worker so per-process weather-source guards do
|
||||
# not multiply outbound Open-Meteo traffic on small VPS deployments.
|
||||
workers=int(os.getenv("UVICORN_WORKERS", "1")),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user