Weather review fixes: zone-fetch time budget, prod NWS_USER_AGENT (Refs #237)

- Cap one refresh's NWS zone fetches at 15 s wall clock. They run under the
  feed lock, so a hung zone endpoint could otherwise stall every
  /weather/alerts request for minutes; unfinished zones retry next refresh.
- Prod c2-core.env.j2 never set NWS_USER_AGENT, so the VM would have sent the
  contact-less placeholder. Default it to the site URL (overridable via
  vault_nws_user_agent); warn on the .env.example example.com placeholder too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:58:32 -04:00
co-authored by Claude Opus 5.5
parent fb1b6956c2
commit bdbde359a1
4 changed files with 40 additions and 2 deletions
+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