Skip to content

Commit e62258c

Browse files
committed
make tides resilient to flaky NOAA upstream
NOAA's API intermittently fails, which surfaced as a dead-end "Failed to load NOAA data" screen. harden it: - noaa: retry transient failures, and serve last-known-good extrema when NOAA is unavailable (stale-if-error) — tide predictions change very slowly - bootstrap: gather stations with return_exceptions and drop any that fail, so one bad station no longer sinks the whole dashboard - frontend: auto-retry the bootstrap a few times, then show a manual retry button instead of a terminal error
1 parent 4c13382 commit e62258c

5 files changed

Lines changed: 160 additions & 57 deletions

File tree

app/tides/noaa.py

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,37 +28,61 @@ class Extremum:
2828
kind: str # "H" | "L"
2929

3030

31-
_CACHE: dict[tuple[str, str, str], tuple[float, list[Extremum]]] = {}
31+
# Keyed by station id (the begin/end window is always ~now±20d, so the last
32+
# successful fetch stays usable). Holds the last-known-good extrema indefinitely
33+
# so we can serve stale data when NOAA is flaky (predictions change very slowly).
34+
_CACHE: dict[str, tuple[float, list[Extremum]]] = {}
35+
36+
_RETRIES = 2 # extra attempts on transient NOAA failures
3237

3338

3439
def _fetch_blocking(station_id: str, begin: str, end: str) -> list[Extremum]:
35-
station = CoopsStation(id=station_id)
36-
df = station.get_data(
37-
begin_date=begin,
38-
end_date=end,
39-
product="predictions",
40-
datum="MLLW",
41-
units="english",
42-
time_zone="lst_ldt",
43-
interval="hilo",
44-
)
45-
out: list[Extremum] = []
46-
for ts, row in df.iterrows():
47-
out.append(Extremum(t=ts.to_pydatetime(), height=float(row["v"]), kind=str(row["type"])))
48-
return out
40+
last_err: Exception | None = None
41+
for attempt in range(_RETRIES + 1):
42+
try:
43+
station = CoopsStation(id=station_id)
44+
df = station.get_data(
45+
begin_date=begin,
46+
end_date=end,
47+
product="predictions",
48+
datum="MLLW",
49+
units="english",
50+
time_zone="lst_ldt",
51+
interval="hilo",
52+
)
53+
out: list[Extremum] = []
54+
for ts, row in df.iterrows():
55+
out.append(
56+
Extremum(t=ts.to_pydatetime(), height=float(row["v"]), kind=str(row["type"]))
57+
)
58+
return out
59+
except Exception as err: # noqa: BLE001 — retry any upstream hiccup
60+
last_err = err
61+
if attempt < _RETRIES:
62+
time.sleep(0.6 * (attempt + 1))
63+
assert last_err is not None
64+
raise last_err
4965

5066

5167
async def fetch_extrema(station_id: str, begin: str, end: str) -> list[Extremum]:
52-
"""Return real NOAA hi/lo predictions for ``[begin, end]`` (YYYYMMDD)."""
53-
key = (station_id, begin, end)
68+
"""Return real NOAA hi/lo predictions for ``[begin, end]`` (YYYYMMDD).
69+
70+
Serves fresh data within the TTL, refetches after, and falls back to the
71+
last-known-good extrema if NOAA is unavailable (stale-if-error).
72+
"""
5473
now = time.monotonic()
55-
cached = _CACHE.get(key)
74+
cached = _CACHE.get(station_id)
5675
if cached is not None and now - cached[0] < CACHE_TTL:
5776
return cached[1]
5877

59-
extrema = await asyncio.to_thread(_fetch_blocking, station_id, begin, end)
60-
_CACHE[key] = (now, extrema)
61-
return extrema
78+
try:
79+
extrema = await asyncio.to_thread(_fetch_blocking, station_id, begin, end)
80+
_CACHE[station_id] = (now, extrema)
81+
return extrema
82+
except Exception:
83+
if cached is not None:
84+
return cached[1] # stale-if-error
85+
raise
6286

6387

6488
def clear_cache() -> None:

app/tides/router.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,34 +62,39 @@ async def _station_payload(s: stations.Station, begin: str, end: str) -> dict[st
6262
@router.get("/bootstrap")
6363
async def bootstrap() -> dict[str, Any]:
6464
"""Everything the dashboard loads on startup, in one round trip."""
65-
try:
66-
now = datetime.now(_TZ)
67-
begin, end = _date_range(now)
68-
payloads = await asyncio.gather(
69-
*(_station_payload(s, begin, end) for s in stations.STATIONS)
70-
)
71-
sm = astro.sun_moon(now.date())
72-
return {
73-
"now": _epoch_ms(now),
74-
"tz": "America/Los_Angeles",
75-
"datum": "MLLW",
76-
"units": "english",
77-
"date": {
78-
"iso": now.strftime("%Y-%m-%d"),
79-
"pretty": now.strftime("%a · %b %-d, %Y"),
80-
},
81-
"sun_moon": {
82-
"sunrise": sm.sunrise,
83-
"sunset": sm.sunset,
84-
"noon": sm.noon,
85-
"moon_phase": sm.moon_phase,
86-
"moon_illum": sm.moon_illum,
87-
"moon_glyph": sm.moon_glyph,
88-
},
89-
"stations": list(payloads),
90-
}
91-
except Exception as exc: # noqa: BLE001 — surface upstream NOAA failures cleanly
92-
raise HTTPException(status_code=502, detail=f"NOAA upstream error: {exc}") from exc
65+
now = datetime.now(_TZ)
66+
begin, end = _date_range(now)
67+
# Don't let one flaky station sink the whole dashboard — gather, then drop
68+
# any that failed (each station already falls back to last-known-good data).
69+
results = await asyncio.gather(
70+
*(_station_payload(s, begin, end) for s in stations.STATIONS),
71+
return_exceptions=True,
72+
)
73+
good = [r for r in results if not isinstance(r, BaseException)]
74+
if not good:
75+
first = next((r for r in results if isinstance(r, BaseException)), None)
76+
raise HTTPException(status_code=502, detail=f"NOAA upstream error: {first}")
77+
78+
sm = astro.sun_moon(now.date())
79+
return {
80+
"now": _epoch_ms(now),
81+
"tz": "America/Los_Angeles",
82+
"datum": "MLLW",
83+
"units": "english",
84+
"date": {
85+
"iso": now.strftime("%Y-%m-%d"),
86+
"pretty": now.strftime("%a · %b %-d, %Y"),
87+
},
88+
"sun_moon": {
89+
"sunrise": sm.sunrise,
90+
"sunset": sm.sunset,
91+
"noon": sm.noon,
92+
"moon_phase": sm.moon_phase,
93+
"moon_illum": sm.moon_illum,
94+
"moon_glyph": sm.moon_glyph,
95+
},
96+
"stations": good,
97+
}
9398

9499

95100
@router.get("/widget")

frontend/src/tides/App.tsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { fetchBootstrap } from './api'
33
import type { Bootstrap } from './types'
44
import { Dashboard } from './Dashboard'
@@ -18,27 +18,58 @@ function useIsMobile(): boolean {
1818
return isMobile
1919
}
2020

21+
// NOAA's API is occasionally flaky; auto-retry a few times before giving up.
22+
const MAX_AUTO_RETRIES = 3
23+
2124
export default function App() {
2225
const [data, setData] = useState<Bootstrap | null>(null)
2326
const [error, setError] = useState<string | null>(null)
2427
const isMobile = useIsMobile()
28+
const attempt = useRef(0)
2529

26-
useEffect(() => {
27-
const ctrl = new AbortController()
28-
fetchBootstrap(ctrl.signal)
29-
.then(setData)
30+
const load = useCallback((signal?: AbortSignal) => {
31+
setError(null)
32+
fetchBootstrap(signal)
33+
.then((d) => {
34+
attempt.current = 0
35+
setData(d)
36+
})
3037
.catch((e) => {
31-
if (e.name !== 'AbortError') setError(String(e.message ?? e))
38+
if (e.name === 'AbortError') return
39+
if (attempt.current < MAX_AUTO_RETRIES) {
40+
attempt.current += 1
41+
setTimeout(() => load(signal), 1200 * attempt.current)
42+
} else {
43+
setError(String(e.message ?? e))
44+
}
3245
})
33-
return () => ctrl.abort()
3446
}, [])
3547

48+
useEffect(() => {
49+
const ctrl = new AbortController()
50+
load(ctrl.signal)
51+
return () => ctrl.abort()
52+
}, [load])
53+
3654
if (error) {
3755
return (
3856
<div className="bto-splash">
3957
<div className="bto-splash-mark"></div>
4058
<div className="bto-splash-title">Bellingham Tidal Observatory</div>
41-
<div className="bto-splash-err">Failed to load NOAA data — {error}</div>
59+
<div className="bto-splash-err">Couldn’t reach NOAA — {error}</div>
60+
<button className="bto-splash-retry" onClick={() => { attempt.current = 0; load() }}>
61+
retry
62+
</button>
63+
</div>
64+
)
65+
}
66+
67+
if (!data) {
68+
return (
69+
<div className="bto-splash">
70+
<div className="bto-splash-mark"></div>
71+
<div className="bto-splash-title">Bellingham Tidal Observatory</div>
72+
<div className="bto-splash-sub">fetching noaa predictions…</div>
4273
</div>
4374
)
4475
}

frontend/src/tides/styles/tides.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,3 +900,18 @@
900900
cursor: pointer;
901901
}
902902
.tic-reset:hover { border-color: rgba(150, 195, 235, 0.6); }
903+
904+
.bto-splash-retry {
905+
margin-top: 16px;
906+
appearance: none;
907+
background: rgba(8, 16, 30, 0.6);
908+
border: 1px solid rgba(150, 195, 235, 0.4);
909+
color: var(--ice-100, #eaf4ff);
910+
border-radius: 6px;
911+
padding: 8px 18px;
912+
font-family: var(--mono);
913+
font-size: 12px;
914+
letter-spacing: 0.04em;
915+
cursor: pointer;
916+
}
917+
.bto-splash-retry:hover { border-color: rgba(150, 195, 235, 0.7); }

tests/test_tides.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@ def test_widget_payload(client: TestClient, fake_noaa: None) -> None:
4747
assert {"t", "v"} <= body["samples"][0].keys()
4848

4949

50+
def test_fetch_extrema_serves_stale_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
51+
import asyncio
52+
53+
from app.tides import noaa as noaa_mod
54+
55+
noaa_mod.clear_cache()
56+
calls = {"n": 0}
57+
good = [noaa.Extremum(t=datetime(2026, 5, 27, 1, 0), height=8.0, kind="H")]
58+
59+
def fake_blocking(station_id: str, begin: str, end: str) -> list[noaa.Extremum]:
60+
calls["n"] += 1
61+
if calls["n"] == 1:
62+
return good
63+
raise RuntimeError("NOAA down")
64+
65+
monkeypatch.setattr(noaa_mod, "_fetch_blocking", fake_blocking)
66+
monkeypatch.setattr(noaa_mod, "CACHE_TTL", 0) # force a refetch on the 2nd call
67+
68+
async def run() -> tuple[list[noaa.Extremum], list[noaa.Extremum]]:
69+
first = await noaa_mod.fetch_extrema("9449211", "20260501", "20260601")
70+
second = await noaa_mod.fetch_extrema("9449211", "20260501", "20260601")
71+
return first, second
72+
73+
first, second = asyncio.run(run())
74+
assert first == good
75+
assert second == good # stale-if-error: returns last-known-good despite failure
76+
77+
5078
def test_bootstrap_shape(client: TestClient, fake_noaa: None) -> None:
5179
res = client.get("/api/tides/bootstrap")
5280
assert res.status_code == 200

0 commit comments

Comments
 (0)