fix(fuel-prices): resilient seeder — proxy, retry, stale-carry-forward, strict gate#3082
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR hardens the fuel-price seeder against the four failure modes logged on Railway 2026-04-07: proxy-first fetching for NZ/BR/MX, a Confidence Score: 5/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Cron as Railway Cron
participant Main as main()
participant Sources as Fetch Sources (×7)
participant Proxy as Proxy (fetchWithProxyPreferred)
participant Redis as Redis (Upstash)
Cron->>Main: seed-fuel-prices.mjs
Main->>Redis: readSeedSnapshot(:prev)
Redis-->>Main: prevSnapshot (or null)
Main->>Sources: Promise.allSettled([MY, MX, US, EU, BR, NZ, GB])
Note over Sources,Proxy: NZ/BR/MX use fetchWithProxyPreferred<br/>+ withFuelRetry (3 attempts, 1.5s/3s)
Sources->>Proxy: proxy-first fetch
Proxy-->>Sources: response or fallback to direct
Sources-->>Main: fulfilled/rejected results
alt Some sources failed
Main->>Redis: lookup failedSources in prevSnapshot
Redis-->>Main: prev entries for failed-source countries
Note over Main: mark as stale:true, preserve observedAt
end
Note over Main: Skip WoW for stale entries<br/>Rank cheapest/expensive from freshCountries only
Main->>Main: "validateFuel(data)<br/>(≥25 countries + US/GB/MY fresh)"
alt validateFuel passes
Main->>Redis: SET canonical key
alt allSourcesFresh AND wowAvailable
Main->>Redis: SET :prev key (rotate snapshot)
else partial failure
Note over Main: [:prev] Skipping rotation
end
Main->>Redis: writeFreshnessMetadata (seed-meta)
else validateFuel fails + emptyDataIsFailure
Main->>Redis: extendExistingTtl (no seed-meta refresh)
end
Reviews (1): Last reviewed commit: "fix(fuel-prices): resilient seeder — pro..." | Re-trigger Greptile |
| export function parseCREStationPrices(xml) { | ||
| const re = (type) => new RegExp(`<gas_price\\s+type="${type}">([\\d.]+)</gas_price>`, 'g'); | ||
| const collect = (type) => [...xml.matchAll(re(type))].map(m => parseFloat(m[1])) | ||
| .filter(v => Number.isFinite(v) && v > 5 && v < 100); | ||
| return { regular: collect('regular'), diesel: collect('diesel') }; | ||
| } |
There was a problem hiding this comment.
parseCREStationPrices not called from fetchMexico
The exported function duplicates the inline parsing logic inside fetchMexico (lines ~231–236) rather than calling it. The comment says "Used by fetchMexico" but fetchMexico owns its own re/collect closures with the same regex and 5 < v < 100 filter. This means the unit tests in seed-fuel-prices.test.mjs validate the exported helper, not the actual production code path. If the price bounds or regex are adjusted in one copy, the other will silently diverge.
| export function parseCREStationPrices(xml) { | |
| const re = (type) => new RegExp(`<gas_price\\s+type="${type}">([\\d.]+)</gas_price>`, 'g'); | |
| const collect = (type) => [...xml.matchAll(re(type))].map(m => parseFloat(m[1])) | |
| .filter(v => Number.isFinite(v) && v > 5 && v < 100); | |
| return { regular: collect('regular'), diesel: collect('diesel') }; | |
| } | |
| export function parseCREStationPrices(xml) { | |
| const re = (type) => new RegExp(`<gas_price\\s+type="${type}">([\\d.]+)</gas_price>`, 'g'); | |
| const collect = (type) => [...xml.matchAll(re(type))].map(m => parseFloat(m[1])) | |
| .filter(v => Number.isFinite(v) && v > 5 && v < 100); | |
| return { regular: collect('regular'), diesel: collect('diesel') }; | |
| } |
And in fetchMexico, replace the duplicated block with:
const { regular, diesel } = parseCREStationPrices(xml);|
|
||
| async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
|
|
||
| // Retry wrapper: 3 attempts, 1.5s/3s/4.5s backoff. Use for all upstream calls. |
There was a problem hiding this comment.
Backoff comment lists a delay that never fires
The comment says "1.5s/3s/4.5s backoff" but with tries = 3, the if (i < tries) guard means the loop only sleeps between attempts 1→2 (1500ms) and 2→3 (3000ms). The 4.5s sleep (i=3, delay=4500ms) is never reached because after the third attempt fails the function throws immediately.
| // Retry wrapper: 3 attempts, 1.5s/3s/4.5s backoff. Use for all upstream calls. | |
| // Retry wrapper: 3 attempts, 1.5s/3s backoff. Use for all upstream calls. | |
| async function withFuelRetry(label, fn, { tries = 3 } = {}) { |
| async function main() { | ||
| const prevSnapshot = await readSeedSnapshot(`${CANONICAL_KEY}:prev`); |
There was a problem hiding this comment.
main() body has no indentation
The entire ~220-line function body sits at column 0, making it visually indistinguishable from module-level code. The wrapping was done without re-indenting the existing block. While JavaScript doesn't require indentation, this makes it hard to spot the function boundary and to reason about scope (e.g., staleCarried, failedSources, countryMap are local to main() but look global).
Consider re-indenting the body with two spaces to match the rest of the file's style.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Addressed both P1s in b899faa. On the stale-carry-forward freshness bug: agreed — inserting On the validator masking outages: tightened the gate to reject ANY partial failure:
New failure model: partial run → validator rejects → Dead code removed: Tests updated to cover the |
…d, strict gate Addresses 2026-04-07 run where 4 of 7 sources failed (NZ 403, BR/MX fetch failed) and the seeder silently published 30 countries with Brazil/Mexico/NZ vanishing from the UI. - Startup proxy diagnostic so PROXY_URL misconfigs are immediately visible. - New fetchWithProxyPreferred (proxy-first, direct fallback) + withFuelRetry (3 attempts, backoff) wrapping NZ/BR/MX upstream calls. - Swap MX from dead datos.gob.mx to CRE publicacionexterna XML (13k stations). - Stale-carry-forward failed sources from :prev snapshot (stale: true) instead of dropping countries; fresh-only ranking; skip WoW for stale entries. - Gate :prev rotation on all-sources-succeeded so partial runs don't poison next week's WoW. - Strict validateFn: >=25 countries AND US+GB+MY fresh. Prior gate was >=1. - emptyDataIsFailure: true so validation fail doesnt refresh seed-meta. - Wrap imperative body in main() + isMain guard; export parseCREStationPrices and validateFuel; 9 new unit tests.
…view) Reviewer flagged two P1s on the prior commit: 1. stale-carry-forward inserted stale: true rows into the published payload, but the proto schema and panel have no staleness render path. Users would see week-old BR/MX/NZ prices as current. Resilience turned into a freshness bug. 2. Validator counted stale-carried entries toward the floor. US/GB/MY fresh + 22 stale still passed, refreshing seed-meta.fetchedAt and leaving health operationally healthy indefinitely. Hid the outage. Fix: remove stale-carry-forward entirely. Tighten validator to require countries.length >= 30, US+GB+MY present, and failedSources.length === 0. Partial-failure runs now rejected → 10-day cache TTL serves last healthy snapshot → health STALE_SEED after maxStaleMin. Correct, visible signal. Drops dead code: SOURCE_COUNTRY_CODES, staleCarried/freshCountries, stale WoW skip. Tests updated for the failedSources gate.
b899faa to
7614c3b
Compare
Brazil gov.br is structurally unreachable from Railway IPs: - Decodo proxy 403s all .gov.br CONNECTs by policy - Direct fetch fails undici TLS handshake from Railway egress After PR #3082 tightened the publish gate to require zero failed sources, every run exits 1 -> Railway "Deployment crashed" banner + STALE_SEED. Add TOLERATED_FAILURES = {'Brazil'}; validateFuel ignores tolerated names when checking failedSources. Critical regions (US/GB/MY) and the >=30 country floor still gate publish. Brazil's outage stays visible via the existing [FRESHNESS] log.
…oop (#3085) * fix(fuel-prices): tolerate Brazil ANP failure to stop Railway crash-loop Brazil gov.br is structurally unreachable from Railway IPs: - Decodo proxy 403s all .gov.br CONNECTs by policy - Direct fetch fails undici TLS handshake from Railway egress After PR #3082 tightened the publish gate to require zero failed sources, every run exits 1 -> Railway "Deployment crashed" banner + STALE_SEED. Add TOLERATED_FAILURES = {'Brazil'}; validateFuel ignores tolerated names when checking failedSources. Critical regions (US/GB/MY) and the >=30 country floor still gate publish. Brazil's outage stays visible via the existing [FRESHNESS] log. * fix(fuel-prices): rotate :prev on tolerated-only failures to keep WoW fresh Reviewer catch: after tolerating Brazil, allSourcesFresh stays false forever → :prev never rotates → panel's WoW stretches into 2-week, 3-week, ... deltas for every non-Brazil country while still labeled 'week-over-week'. Gate :prev rotation on untolerated failures only. Tolerated sources are absent from the snapshot entirely, so rotating is safe (no stale-self- compare poisoning next week). * fix(fuel-prices): distinguish tolerated vs untolerated sources in [DEGRADED] log Greptile P2: the [DEGRADED] message said 'publish will be rejected' even when only tolerated sources (Brazil) failed — confusing for operators watching Railway logs.
Depends on #3078 (uses the
emptyDataIsFailure: trueopt-in added there).Why
Railway log 2026-04-07 showed
seed-fuel-pricesrunning with 4 of 7 sources failing:The seeder still published 30 countries — with Brazil, Mexico, and New Zealand silently disappearing from the UI. The prior validator was
countries.length >= 1,MAX_DROP_PCT=50only warned, and:prevgot rotated anyway which would have poisoned next week's WoW calc. Not up to our standards.Root causes
fetchWithProxyFallbackneededPROXY_URLset but never logged whether it was[PROXY] configured/NOT configureddiagnostic linefetchWithProxyPreferred(proxy-first) for NZ/BR/MXwithFuelRetry(label, fn)— 3 attempts, 1.5s/3s backoffapi.datos.gob.mx/v2/precio.gasolina.publicowent unresponsive (IPv4 connect hangs globally — verified from residential IPs)publicacionexterna.azurewebsites.net/publicaciones/pricesXML; parses 13k regular + 10k diesel stations:prevwithstale: true+ originalobservedAt:prevrotated after partial runs → poisons WoW for a failed source's countries:prevwhen ALL sources succeededfreshCountriesonlyvalidateFn: countries.length >= 1accepted anything>= 25ANDUS+GB+MYpresent as FRESH (not stale-carried)seed-metaand blocked retriesemptyDataIsFailure: true(from #3078)Testability
Wrapped imperative body in
async function main()withisMainguard (per memory rulefeedback_seed_isMain_guard.md) so test imports don't trigger Redis calls. ExportsparseCREStationPrices+validateFuel.Test plan
node --test tests/seed-fuel-prices.test.mjs— 9/9 pass (XML parse, range filter, validator contract for missing/stale critical)npm run test:data— 5183/5183 passnpm run typecheck/typecheck:api/lint/lint:md/version:check[MX] 13429 stations, [GB] [MY] [US] [EU] 27 countriesall working via proxy-fallbackseed-fuel-pricesrun logs[PROXY] configuredand returns all 7 sources or stale-carries the failed ones[:prev] Skipping rotationand the:prevkey retains the previous fresh snapshot