diff --git a/climate.py b/climate.py index 456bf4a..8be4633 100644 --- a/climate.py +++ b/climate.py @@ -617,9 +617,17 @@ def _load_recent_forecast(cell: dict) -> pl.DataFrame: # the forecast / day-ahead views working in a degraded form. try: df = _fetch_forecast_metno(cell) - except Exception: # noqa: BLE001 - backup unavailable too; surface the original - # Classify the original Open-Meteo rate limit so callers get the typed - # error (the archive path classifies its own inside _load_history). + except Exception: # noqa: BLE001 - backup unavailable too + # Last resort: serve the stale cache if we have one. An hours-old bundle + # (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): daily = "daily" in _rate_limit_reason(e).lower() raise WeatherUnavailable(limit_message(daily), daily=daily) from e diff --git a/tests/test_climate.py b/tests/test_climate.py index 857dab0..283abe3 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -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) +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(): 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")