Serve stale forecast cache when both Open-Meteo and MET Norway fail (#116)
Complete the forecast fallback chain: Open-Meteo → MET Norway → stale cache. When both live sources are unavailable, serve the last cached recent/forecast bundle even if it is past its 1-hour TTL — an hours-old bundle (which still carries the recent observed days MET Norway lacks) beats a hard 503, mirroring the archive path's stale-serve in _load_history. The stale bundle is returned WITHOUT rewriting it, so its mtime stays old and the next request retries the live sources first rather than treating the stale copy as fresh. Only when there is no cache at all does the typed WeatherUnavailable surface.
This commit is contained in:
parent
6927a53ea0
commit
8260948dd8
2 changed files with 35 additions and 3 deletions
14
climate.py
14
climate.py
|
|
@ -617,9 +617,17 @@ def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
# the forecast / day-ahead views working in a degraded form.
|
# the forecast / day-ahead views working in a degraded form.
|
||||||
try:
|
try:
|
||||||
df = _fetch_forecast_metno(cell)
|
df = _fetch_forecast_metno(cell)
|
||||||
except Exception: # noqa: BLE001 - backup unavailable too; surface the original
|
except Exception: # noqa: BLE001 - backup unavailable too
|
||||||
# Classify the original Open-Meteo rate limit so callers get the typed
|
# Last resort: serve the stale cache if we have one. An hours-old bundle
|
||||||
# error (the archive path classifies its own inside _load_history).
|
# (which still carries the recent observed days MET Norway lacks) beats a
|
||||||
|
# hard failure, mirroring _load_history's stale-serve. Return WITHOUT
|
||||||
|
# rewriting it, so its mtime stays old and the next request still retries
|
||||||
|
# upstream first rather than serving this as if it were fresh.
|
||||||
|
if os.path.exists(path):
|
||||||
|
return _with_doy(_normalize_read(pl.read_parquet(path)))
|
||||||
|
# No cache either: surface the original error, classifying an Open-Meteo
|
||||||
|
# rate limit as the typed, daily-aware WeatherUnavailable (the archive
|
||||||
|
# path classifies its own inside _load_history).
|
||||||
if is_rate_limit(e):
|
if is_rate_limit(e):
|
||||||
daily = "daily" in _rate_limit_reason(e).lower()
|
daily = "daily" in _rate_limit_reason(e).lower()
|
||||||
raise WeatherUnavailable(limit_message(daily), daily=daily) from e
|
raise WeatherUnavailable(limit_message(daily), daily=daily) from e
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,30 @@ def test_recent_forecast_falls_back_to_metno(monkeypatch, tmp_path):
|
||||||
assert len(df) == 2 and df["date"].max() == datetime.date(2026, 7, 17)
|
assert len(df) == 2 and df["date"].max() == datetime.date(2026, 7, 17)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path):
|
||||||
|
"""With Open-Meteo AND MET Norway both down, an existing (stale) cache is served
|
||||||
|
rather than failing — and it is NOT rewritten, so its mtime stays old and the
|
||||||
|
next request still retries upstream first."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
monkeypatch.setattr(climate, "CACHE_DIR", str(tmp_path))
|
||||||
|
cell = {"id": "stale_cell", "center_lat": 47.6, "center_lon": -122.3}
|
||||||
|
# Seed an rf cache, then age it well past the TTL so it counts as stale.
|
||||||
|
stale = climate._to_frame(_om_daily())
|
||||||
|
path = climate._rf_cache_path(cell["id"])
|
||||||
|
climate._write_cache(stale, path)
|
||||||
|
old = time.time() - (climate.FORECAST_TTL_HOURS + 5) * 3600
|
||||||
|
os.utime(path, (old, old))
|
||||||
|
|
||||||
|
def all_down(url, params, timeout, *, phase, headers=None, attempts=climate.MAX_ATTEMPTS):
|
||||||
|
raise RuntimeError(f"{phase} down")
|
||||||
|
monkeypatch.setattr(climate, "_request", all_down)
|
||||||
|
|
||||||
|
df = climate._load_recent_forecast(cell)
|
||||||
|
assert df.height == stale.height # served the stale cache
|
||||||
|
assert abs(os.path.getmtime(path) - old) < 2 # not rewritten (mtime unchanged)
|
||||||
|
|
||||||
|
|
||||||
def test_om_daily_params_carries_the_window():
|
def test_om_daily_params_carries_the_window():
|
||||||
cell = {"center_lat": 47.6, "center_lon": -122.3}
|
cell = {"center_lat": 47.6, "center_lon": -122.3}
|
||||||
p = climate._om_daily_params(cell, start_date="2026-01-01", end_date="2026-02-01")
|
p = climate._om_daily_params(cell, start_date="2026-01-01", end_date="2026-02-01")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue