Weather backend: NWS active alerts + nearest-METAR wind, cached (Refs #237) #263

Merged
logan merged 2 commits from feat/237-weather-api into main 2026-10-05 03:03:33 -04:00
4 changed files with 40 additions and 2 deletions
Showing only changes of commit bdbde359a1 - Show all commits
+12 -2
View File
@@ -48,6 +48,11 @@ ZONE_CONCURRENCY = 6
# the next refresh (the zone cache fills across refreshes); meanwhile the alert
# shows in zone_only or with the zones resolved so far.
MAX_ZONE_FETCHES_PER_REFRESH = 80
# Wall-clock cap on one refresh's zone fetches. The refresh runs under the feed
# lock, so without it a slow zone endpoint (20 s timeout x 80 fetches / 6 wide)
# could hold every /weather/alerts request for minutes. Unfinished zones are
# simply not cached and are retried next refresh.
ZONE_BUDGET_S = 15.0
WIND_TTL_S = 5 * 60
WIND_CELL_DEG = 0.5 # wind cache cell; one upstream call per cell per TTL
@@ -61,7 +66,7 @@ _warned_ua = False
def _headers(accept: str) -> Dict[str, str]:
global _warned_ua
ua = settings.nws_user_agent or DEFAULT_USER_AGENT
if ua == DEFAULT_USER_AGENT and not _warned_ua:
if (ua == DEFAULT_USER_AGENT or "example.com" in ua) and not _warned_ua:
_warned_ua = True
logger.warning("NWS_USER_AGENT is the placeholder -- set it to 'app (contact email)'; NWS may block us")
return {"User-Agent": ua, "Accept": accept}
@@ -193,7 +198,12 @@ async def _resolve_zones(client: "httpx.AsyncClient", alerts: List[Dict[str, Any
wanted.append(z)
if wanted:
sem = asyncio.Semaphore(ZONE_CONCURRENCY)
await asyncio.gather(*(_fetch_zone(client, z, sem) for z in wanted[:MAX_ZONE_FETCHES_PER_REFRESH]))
try:
await asyncio.wait_for(
asyncio.gather(*(_fetch_zone(client, z, sem) for z in wanted[:MAX_ZONE_FETCHES_PER_REFRESH])),
timeout=ZONE_BUDGET_S)
except asyncio.TimeoutError:
logger.warning(f"NWS zone fetches over {ZONE_BUDGET_S:.0f}s budget; the rest resolve next refresh")
for a in alerts:
if a["geometry"] is not None:
continue
+21
View File
@@ -216,3 +216,24 @@ def test_wind_unavailable_reasons():
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.METAR_URL: Resp(status=503)})):
body = client.get("/weather/wind", params={"lat": 41.03, "lon": -73.76}).json()
assert body["available"] is False and "unavailable" in body["reason"] and "503" in body["error"]
def test_slow_zone_endpoint_cannot_hold_the_feed_lock():
"""Zone fetches run under the feed lock; a hung zone endpoint must not stall
every /weather/alerts request -- the alert lands in zone_only instead."""
import time
class SlowZones(FakeHttp):
async def get(self, url, params=None, headers=None):
if url.startswith(weather.NWS_ZONE_PREFIX):
await asyncio.sleep(5)
return await super().get(url, params, headers)
http = SlowZones({weather.NWS_ALERTS_URL: Resp({"features": [_alert("z1", "Flood Watch", None, [ZONE_A])]}),
ZONE_A: Resp({"geometry": ZONE_POLY})})
with patch.object(weather, "ZONE_BUDGET_S", 0.2), patch.object(weather.httpx, "AsyncClient", http):
t0 = time.time()
feed = asyncio.run(weather.alerts.get())
assert time.time() - t0 < 2
assert feed.error is None and [z["id"] for z in feed.zone_only] == ["z1"]
assert weather.zones.get(ZONE_A) == (False, None) # not cached as a failure; retried next refresh
@@ -44,3 +44,8 @@ AGENT_SERVICE_KEY={{ vault_agent_service_key | default('') }}
# frontend call to the API would have failed CORS. If the frontend ever moves
# to app.{{ domain }}, change this at the same time.
CORS_ORIGINS=["https://{{ domain }}"]
# NWS weather alerts / METAR wind (server-26#237). api.weather.gov requires a
# User-Agent naming the app and a contact; default to the site URL so a vault
# that predates this key still sends an identifiable agent, not the placeholder.
NWS_USER_AGENT={{ vault_nws_user_agent | default('DRB scanner map (https://' ~ domain ~ ')', true) }}
+2
View File
@@ -60,3 +60,5 @@ vault_firebase_app_id: ""
# No GCP key needed — the VM uses Application Default Credentials via the
# GCE metadata server. Terraform grants the required IAM roles at apply time.
vault_nws_user_agent: "" # optional; e.g. "DRB scanner map (ops@yourdomain)" -- blank/absent uses the site URL