fix(seed-economy): retry proxy/EIA transients; gate stress index on full FRED coverage#3080
Conversation
…issing FRED components Log review of 16 runs (2026-04-14 00:45–04:31 UTC) showed 50% degraded: - Decodo proxy flapped with HTTP 500/502/522, `fredFetchJson` fell back direct on first proxy error and FRED then returned 500 to Railway IP, dropping series. - 5 EIA panels (EnergyPrices, Crude, NatGas, SPR, Refinery) timed out in lockstep at 01:00 and 02:00, producing full `no write` runs. - StressIndex silently excluded missing FRED components (VIXCLS, T10Y3M, STLFSI4, ICSA), publishing a degraded composite as if healthy. Changes: - fredFetchJson: retry proxy 3x with jittered backoff on 5xx/522/timeout before falling back direct. - eiaFetchJson helper: 20s timeout (was 10s) + 3x retry on 5xx/timeout; wired into all EIA call-sites. - computeStressIndex: throw when any FRED-sourced component is missing; GSCPI (ais-relay) can still be absent. Caught in fetchAll so other secondary writes proceed but composite is not published degraded.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR hardens
Confidence Score: 4/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[fredFetchJson] -->|proxyAuth set| B{proxy attempt 1–3}
B -->|success| C[return JSON]
B -->|transient 5xx/522/timeout\nattempt < 3| D[jittered backoff\n400ms×attempt]
D --> B
B -->|non-transient OR\nattempt 3 failed| E[direct fetch\n20s timeout]
E -->|ok| C
E -->|fail| F[throw direct error]
A -->|no proxyAuth| E
G[eiaFetchJson] --> H{fetch attempt 1–3}
H -->|resp.ok| I[return JSON]
H -->|5xx + attempt < 3| J[sleep 500ms×i\nthen retry]
J --> H
H -->|4xx OR last attempt| K[throw error]
H -->|network error + last attempt| K
H -->|network error + not last| J
L[computeStressIndex] --> M{FRED component missing?}
M -->|comp = GSCPI| N[warn + skip\ncontinue]
M -->|comp ≠ GSCPI| O[throw — refuse\npartial composite]
O --> P[fetchAll catch:\nconsole.warn, skip write]
M -->|all present| Q[compute weighted\ncomposite score]
Q --> R[writeExtraKeyWithMeta]
Reviews (1): Last reviewed commit: "fix(seed-economy): retry proxy/EIA trans..." | Re-trigger Greptile |
| if (resp.status < 500 || i === attempts) throw err; | ||
| lastErr = err; | ||
| } catch (e) { | ||
| lastErr = e; | ||
| if (i === attempts) throw e; | ||
| } |
There was a problem hiding this comment.
4xx errors are silently retried
throw err on line 48 is inside the try block, so it is caught by the catch on line 50. The catch only re-throws when i === attempts, which means a 4xx response (e.g. 401, 403, 404) on attempt 1 or 2 is swallowed, the loop sleeps (~900 ms then ~1.4 s), and the same doomed request is retried — the opposite of "fail fast on client errors." The intent of resp.status < 500 in the condition was to bail immediately on 4xx, but that intent is defeated by the surrounding try/catch.
Fix: separate the network-error catch from the HTTP-status check so that 4xx responses throw directly without entering the catch:
| if (resp.status < 500 || i === attempts) throw err; | |
| lastErr = err; | |
| } catch (e) { | |
| lastErr = e; | |
| if (i === attempts) throw e; | |
| } | |
| if (resp.ok) return await resp.json(); | |
| const err = new Error(`EIA ${label}: HTTP ${resp.status}`); | |
| if (resp.status < 500 || i === attempts) throw err; // 4xx: always fail fast; 5xx last attempt: give up | |
| lastErr = err; | |
| } catch (e) { | |
| lastErr = e; | |
| // Re-throw on last attempt OR if this is a non-5xx HTTP error (status attached) | |
| if (i === attempts) throw e; | |
| } |
A cleaner structural fix that keeps the intent unambiguous is to split the try into a network-only catch and handle HTTP status outside it:
async function eiaFetchJson(url, label, { timeoutMs = 20_000, attempts = 3 } = {}) {
let lastErr;
for (let i = 1; i <= attempts; i++) {
let resp;
try {
resp = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
lastErr = e;
if (i === attempts) throw e;
await new Promise((r) => setTimeout(r, 500 * i + Math.random() * 400));
continue;
}
if (resp.ok) return await resp.json();
const err = new Error(`EIA ${label}: HTTP ${resp.status}`);
if (resp.status < 500 || i === attempts) throw err; // 4xx: immediate; 5xx final: give up
lastErr = err;
await new Promise((r) => setTimeout(r, 500 * i + Math.random() * 400));
}
throw lastErr;
}…etchJson - computeStressIndex try/catch no longer wraps the Redis write so a write failure surfaces as a run error instead of being swallowed. - eiaFetchJson bails immediately on 4xx and non-transient thrown errors; only 5xx / timeouts / network resets are retried.
Summary
Log review of 16 seed-economy runs (2026-04-14 00:45–04:31 UTC) showed 50% degraded, 2
no writefailures, and silent StressIndex degradation. Zero-tolerance fixes:fredFetchJson: retry Decodo proxy 3× with jittered backoff on 5xx/522/timeout before falling back direct (was: one direct retry → FRED's datacenter 500 instantly dropped the series).eiaFetchJsonhelper: 20s timeout (was 10s) + 3× retry on 5xx/timeout. Wired into all 5 EIA call-sites (energy prices, crude, nat-gas, SPR, refinery). Eliminates the lockstep timeout cascade that produced fullno writeruns at 01:00 and 02:00.fetchAllso other secondary writes proceed.No cadence, TTL, or key changes.
Test plan
npm run typecheck+typecheck:apinpm run lint(no new errors)npm run test:data— 5174/5174 passnode --test tests/edge-functions.test.mjs— 167/167 passnpm run lint:md/version:checkseed-economylogs for FRED 24/24 rate and absence ofStressIndex … missing+ silent composite writes