Skip to content

Commit e25f62c

Browse files
authored
fix(radar): stop serving an expired forecast when FMI is down (#1009)
`Cache::get_stale` matched on key alone, with no age bound, so a flaky upstream pinned the last good response indefinitely. FMI's download service drops connections mid-response often enough to exhaust the 3-attempt retry budget, and the radar handler then fell back to whatever was in the cache — overnight, that was the previous evening's grid. Because the cache is server-side and shared, the panel only recovered when some other client's request happened to catch FMI healthy. - `get_stale` now requires a `max_age`; all six call sites pass a bound. 3 h for the precip forecast, 1 h for radar frames and Solis live state, 6 h for weather and spot — generous enough that nothing else changes in practice, but no endpoint can serve yesterday's data as today's. - Forecast frames carry absolute valid times, so a cached grid ages out frame by frame rather than all at once. `future_frames` trims the elapsed head on every response path; nothing left means 502, which the frontend's retry and "ei saatavilla" hint already cover. - `Cache-Control: no-store` on the `/api` scope, mirrored in the fetchers. iOS Safari heuristically caches unlabelled GETs. `DefaultHeaders` only fills an absent header, so the WMS tile route keeps its max-age=300. - RainMap refetches on `visibilitychange` (iOS suspends refreshInterval while backgrounded), trims its in-memory copy for the gap before that lands, and derives the "+N h" label from each frame's own timestamp instead of its array index.
1 parent 2aef7c4 commit e25f62c

8 files changed

Lines changed: 259 additions & 35 deletions

File tree

‎backend/src/cache.rs‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,15 @@ impl<T: Clone> Cache<T> {
3232
})
3333
}
3434

35-
/// Returns cached data even if expired (for fallback on error), only if key matches.
36-
pub async fn get_stale(&self, key: &str) -> Option<T> {
35+
/// Cached data past its TTL, for use as a fallback when the upstream fails.
36+
/// Matches on key like [`Cache::get`], but `max_age` bounds how stale is
37+
/// still useful: without it a dead upstream pins the last good response
38+
/// forever, and the caller has no way to tell it apart from a fresh one.
39+
pub async fn get_stale(&self, key: &str, max_age: Duration) -> Option<T> {
3740
let guard = self.inner.read().await;
3841
guard
3942
.as_ref()
40-
.filter(|entry| entry.key == key)
43+
.filter(|entry| entry.key == key && entry.created_at.elapsed() < max_age)
4144
.map(|entry| entry.data.clone())
4245
}
4346

@@ -61,3 +64,34 @@ impl<T: Clone> Cache<T> {
6164
self.inner.read().await.is_some()
6265
}
6366
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
72+
const HOUR: Duration = Duration::from_secs(3600);
73+
74+
#[tokio::test]
75+
async fn get_stale_serves_a_recent_entry_past_its_ttl() {
76+
let cache = Cache::new(Duration::ZERO); // already expired for `get`
77+
cache.set("k".into(), 42).await;
78+
assert_eq!(cache.get("k").await, None);
79+
assert_eq!(cache.get_stale("k", HOUR).await, Some(42));
80+
}
81+
82+
/// The bound is the point of the parameter: an upstream that has been down
83+
/// long enough must stop resurrecting its last good response.
84+
#[tokio::test]
85+
async fn get_stale_refuses_an_entry_older_than_max_age() {
86+
let cache = Cache::new(HOUR);
87+
cache.set("k".into(), 42).await;
88+
assert_eq!(cache.get_stale("k", Duration::ZERO).await, None);
89+
}
90+
91+
#[tokio::test]
92+
async fn get_stale_still_matches_on_key() {
93+
let cache = Cache::new(HOUR);
94+
cache.set("k".into(), 42).await;
95+
assert_eq!(cache.get_stale("other", HOUR).await, None);
96+
}
97+
}

‎backend/src/lib.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,14 @@ pub fn create_app(
194194
.route("/status", web::get().to(status))
195195
.service(
196196
web::scope("/api")
197+
// These payloads are all live state, and none of them carry a
198+
// validator. iOS Safari heuristically caches an unlabelled GET,
199+
// which leaves an installed PWA rendering last night's data
200+
// against a backend that has long since moved on. `DefaultHeaders`
201+
// only fills in a header that is absent, so the radar tile route's
202+
// own `public, max-age=300` still stands — tiles stay cacheable,
203+
// JSON does not.
204+
.wrap(middleware::DefaultHeaders::new().add(("Cache-Control", "no-store")))
197205
.service(
198206
web::scope("/history")
199207
.route("/sensors", web::get().to(sensor_history))

‎backend/src/radar/handlers.rs‎

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1-
use std::sync::Arc;
1+
use std::{sync::Arc, time::Duration};
22

33
use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse};
4+
use chrono::{DateTime, Utc};
45
use serde::Deserialize;
56

6-
use super::models::DEFAULT_LAYER;
7+
use super::models::{PrecipForecast, DEFAULT_LAYER};
78
use crate::AppState;
89

10+
/// Upper bound on the forecast we will fall back to when FMI is unreachable.
11+
/// Frames are dropped individually as they age out (see [`future_frames`]), so
12+
/// this is only a backstop against handing out a grid whose remaining frames
13+
/// come from a model run too old to trust.
14+
const FORECAST_MAX_STALE: Duration = Duration::from_secs(3 * 3600);
15+
16+
/// Frame timestamps go straight into the WMS `time` dimension, and FMI keeps
17+
/// only a short window of radar composites online — an older list would just
18+
/// render as empty tiles.
19+
const FRAMES_MAX_STALE: Duration = Duration::from_secs(3600);
20+
921
#[derive(Debug, Deserialize, utoipa::IntoParams)]
1022
pub struct ForecastQuery {
1123
/// Latitude of the map centre.
@@ -32,9 +44,16 @@ pub async fn forecast(
3244
let hours = query.hours.unwrap_or(12).clamp(1, 24);
3345
// Round the location into the cache key so small map nudges reuse the grid.
3446
let key = format!("{:.2},{:.2}:{hours}", query.lat, query.lon);
47+
let now = Utc::now();
3548

49+
// Every exit trims against the *current* clock, and the cache keeps the
50+
// untrimmed grid — the same entry is served to a request an hour later
51+
// minus one more frame, rather than being frozen at whatever "future"
52+
// meant when it was fetched.
3653
if let Some(cached) = state.precip_forecast_cache.get(&key).await {
37-
return HttpResponse::Ok().json(cached);
54+
if let Some(fresh) = future_frames(cached, now) {
55+
return HttpResponse::Ok().json(fresh);
56+
}
3857
}
3958

4059
match super::forecast::fetch_forecast(
@@ -48,20 +67,54 @@ pub async fn forecast(
4867
{
4968
Ok(forecast) => {
5069
state.precip_forecast_cache.set(key, forecast.clone()).await;
51-
HttpResponse::Ok().json(forecast)
70+
match future_frames(forecast, now) {
71+
Some(fresh) => HttpResponse::Ok().json(fresh),
72+
None => no_forecast(),
73+
}
5274
}
5375
Err(e) => {
5476
tracing::error!("Failed to fetch precip forecast: {e}");
55-
if let Some(stale) = state.precip_forecast_cache.get_stale(&key).await {
56-
HttpResponse::Ok().json(stale)
57-
} else {
58-
HttpResponse::BadGateway()
59-
.json(serde_json::json!({"error": "No forecast available"}))
77+
match state
78+
.precip_forecast_cache
79+
.get_stale(&key, FORECAST_MAX_STALE)
80+
.await
81+
.and_then(|stale| future_frames(stale, now))
82+
{
83+
Some(stale) => {
84+
tracing::warn!("Returning stale precip forecast");
85+
HttpResponse::Ok().json(stale)
86+
}
87+
None => no_forecast(),
6088
}
6189
}
6290
}
6391
}
6492

93+
fn no_forecast() -> HttpResponse {
94+
HttpResponse::BadGateway().json(serde_json::json!({"error": "No forecast available"}))
95+
}
96+
97+
/// Keep only the frames whose valid time is still ahead.
98+
///
99+
/// Frames carry absolute times, so a cached grid ages out frame by frame rather
100+
/// than all at once: at 22:30 a 20:00 model run is still a real forecast for
101+
/// 23:00 onward. Without this, a cache entry that outlives its own horizon —
102+
/// which is exactly what happens when FMI is down overnight — gets rendered as
103+
/// "+1 h … +12 h" and shows last night's rain as tomorrow's.
104+
///
105+
/// `None` when nothing is left, so the caller can 502 instead of shipping an
106+
/// empty timeline.
107+
fn future_frames(mut fc: PrecipForecast, now: DateTime<Utc>) -> Option<PrecipForecast> {
108+
// An unparseable time is kept: it would mean the format changed and this
109+
// was not updated, and silently emptying the forecast is the worse failure.
110+
fc.frames
111+
.retain(|f| match DateTime::parse_from_rfc3339(&f.time) {
112+
Ok(t) => t.with_timezone(&Utc) > now,
113+
Err(_) => true,
114+
});
115+
(!fc.frames.is_empty()).then_some(fc)
116+
}
117+
65118
#[derive(Debug, Deserialize, utoipa::IntoParams)]
66119
pub struct FramesQuery {
67120
/// WMS layer to animate (defaults to the nationwide reflectivity composite).
@@ -105,7 +158,11 @@ pub async fn frames(
105158
}
106159
Err(e) => {
107160
tracing::error!("Failed to fetch radar frames: {e}");
108-
if let Some(stale) = state.radar_frames_cache.get_stale(&key).await {
161+
if let Some(stale) = state
162+
.radar_frames_cache
163+
.get_stale(&key, FRAMES_MAX_STALE)
164+
.await
165+
{
109166
HttpResponse::Ok().json(stale)
110167
} else {
111168
HttpResponse::BadGateway()
@@ -194,6 +251,60 @@ fn without_sld_body(query: &str) -> Option<String> {
194251
#[cfg(test)]
195252
mod tests {
196253
use super::*;
254+
use crate::radar::models::ForecastFrame;
255+
256+
fn forecast(times: &[&str]) -> PrecipForecast {
257+
PrecipForecast {
258+
bbox: [59.0, 22.0, 61.0, 26.0],
259+
cols: 1,
260+
rows: 1,
261+
unit: "mm/h".into(),
262+
frames: times
263+
.iter()
264+
.map(|t| ForecastFrame {
265+
time: (*t).into(),
266+
max: 1.0,
267+
values: vec![1.0],
268+
})
269+
.collect(),
270+
}
271+
}
272+
273+
fn at(s: &str) -> DateTime<Utc> {
274+
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
275+
}
276+
277+
#[test]
278+
fn future_frames_keeps_an_all_future_forecast_intact() {
279+
let fc = forecast(&["2026-06-22T19:00:00Z", "2026-06-22T20:00:00Z"]);
280+
let kept = future_frames(fc, at("2026-06-22T18:10:00Z")).unwrap();
281+
assert_eq!(kept.frames.len(), 2);
282+
}
283+
284+
/// The overnight case: an evening model run reached from the morning has
285+
/// nothing left to say, and must not be served as "+1 h".
286+
#[test]
287+
fn future_frames_rejects_a_wholly_elapsed_forecast() {
288+
let fc = forecast(&["2026-06-22T19:00:00Z", "2026-06-22T20:00:00Z"]);
289+
assert!(future_frames(fc, at("2026-06-23T07:00:00Z")).is_none());
290+
}
291+
292+
#[test]
293+
fn future_frames_drops_only_the_elapsed_head() {
294+
let fc = forecast(&[
295+
"2026-06-22T19:00:00Z",
296+
"2026-06-22T20:00:00Z",
297+
"2026-06-22T21:00:00Z",
298+
]);
299+
let kept = future_frames(fc, at("2026-06-22T20:30:00Z")).unwrap();
300+
assert_eq!(
301+
kept.frames
302+
.iter()
303+
.map(|f| f.time.as_str())
304+
.collect::<Vec<_>>(),
305+
["2026-06-22T21:00:00Z"],
306+
);
307+
}
197308

198309
#[test]
199310
fn without_sld_body_drops_only_the_style_param() {

‎backend/src/solis/handlers.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
use std::sync::Arc;
1+
use std::{sync::Arc, time::Duration};
22

33
use actix_web::{web, HttpResponse};
44
use serde::Deserialize;
55
use utoipa::IntoParams;
66

77
use crate::AppState;
88

9+
/// This is live inverter state — power flows, battery SoC. An hour past the
10+
/// 5-min TTL is already generous; beyond that the numbers are fiction and a
11+
/// 502 (which the frontend renders as unavailable) is the honest answer.
12+
const MAX_STALE: Duration = Duration::from_secs(3600);
13+
914
#[utoipa::path(
1015
get,
1116
path = "/api/solis",
@@ -33,7 +38,7 @@ pub async fn get_data(state: web::Data<Arc<AppState>>) -> HttpResponse {
3338
}
3439
Err(e) => {
3540
tracing::error!("Failed to fetch SolisCloud data: {e}");
36-
if let Some(stale) = state.solis_cache.get_stale("station").await {
41+
if let Some(stale) = state.solis_cache.get_stale("station", MAX_STALE).await {
3742
tracing::warn!("Returning stale SolisCloud data");
3843
return HttpResponse::Ok().json(stale);
3944
}

‎backend/src/spot/handlers.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
use std::sync::Arc;
1+
use std::{sync::Arc, time::Duration};
22

33
use actix_web::{web, HttpResponse};
44

55
use crate::AppState;
66

7+
/// A spot response is a full day-ahead array, so it stays useful for hours past
8+
/// the 10-min TTL — but not across midnight, where "today" silently becomes
9+
/// yesterday's prices.
10+
const MAX_STALE: Duration = Duration::from_secs(6 * 3600);
11+
712
#[utoipa::path(
813
get,
914
path = "/api/spot",
@@ -24,7 +29,7 @@ pub async fn get_spot(state: web::Data<Arc<AppState>>) -> HttpResponse {
2429
}
2530
Err(e) => {
2631
tracing::error!("Failed to fetch spot prices: {e}");
27-
if let Some(stale) = state.spot_cache.get_stale("spot").await {
32+
if let Some(stale) = state.spot_cache.get_stale("spot", MAX_STALE).await {
2833
tracing::warn!("Returning stale spot prices");
2934
return HttpResponse::Ok().json(stale);
3035
}

‎backend/src/weather/handlers.rs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
use std::sync::Arc;
1+
use std::{sync::Arc, time::Duration};
22

33
use actix_web::{web, HttpResponse};
44
use serde::Deserialize;
55

66
use super::fmi::converter::FmiConverter;
77
use crate::AppState;
88

9+
/// How old a cached observation may be before we stop offering it as a
10+
/// fallback. Comfortably past the 1 h TTL — a brief upstream wobble should not
11+
/// blank the dashboard — but short of the point where "current conditions" have
12+
/// become yesterday's.
13+
const MAX_STALE: Duration = Duration::from_secs(6 * 3600);
14+
915
#[derive(Debug, Deserialize)]
1016
pub struct LocationQuery {
1117
pub lat: String,
@@ -66,7 +72,7 @@ pub async fn fmi(
6672
}
6773

6874
async fn fmi_fallback_or_error(state: &AppState, key: &str) -> HttpResponse {
69-
if let Some(stale) = state.weather_cache.get_stale(key).await {
75+
if let Some(stale) = state.weather_cache.get_stale(key, MAX_STALE).await {
7076
tracing::warn!("Returning stale cached weather data");
7177
HttpResponse::Ok().json(stale)
7278
} else {
@@ -112,7 +118,7 @@ pub async fn tomorrow(
112118
}
113119

114120
async fn tomorrow_fallback_or_error(state: &AppState, key: &str) -> HttpResponse {
115-
if let Some(stale) = state.tomorrow_cache.get_stale(key).await {
121+
if let Some(stale) = state.tomorrow_cache.get_stale(key, MAX_STALE).await {
116122
tracing::warn!("Returning stale cached weather data");
117123
HttpResponse::Ok().json(stale)
118124
} else {

‎frontend/src/api.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ const API_BASE = import.meta.env.VITE_API_URL ?? "";
22

33
export const api = (path: string) => `${API_BASE}${path}`;
44

5-
export const fetcher = (url: string) => fetch(url).then((res) => res.json());
5+
// `no-store` on every API read: none of these responses carry a validator, and
6+
// iOS Safari heuristically caches an unlabelled GET — an installed PWA then
7+
// keeps serving last night's payload out of its own disk cache no matter how
8+
// often SWR revalidates. The backend sends `Cache-Control: no-store` too; this
9+
// is the same belt-and-braces as `useDeployReload`.
10+
export const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
611

712
export class HttpError extends Error {
813
status: number;
@@ -14,7 +19,7 @@ export class HttpError extends Error {
1419
}
1520

1621
export const jsonFetcher = async (url: string) => {
17-
const res = await fetch(url);
22+
const res = await fetch(url, { cache: "no-store" });
1823
if (!res.ok) throw new HttpError(res.status);
1924
return res.json();
2025
};

0 commit comments

Comments
 (0)