Skip to content

fix(seed-climate-zone-normals): proxy fallback when Open-Meteo 429s on Railway IP#3118

Merged
koala73 merged 2 commits intomainfrom
fix/open-meteo-proxy-fallback
Apr 16, 2026
Merged

fix(seed-climate-zone-normals): proxy fallback when Open-Meteo 429s on Railway IP#3118
koala73 merged 2 commits intomainfrom
fix/open-meteo-proxy-fallback

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Apr 16, 2026

Summary

Surfaced during PR #3097 bake on 2026-04-16: seed-climate-zone-normals failed every batch with HTTP 429 from Open-Meteo's free-tier per-IP throttle. The seeder retried with 2/4/8/16s backoff but exhausted without ever falling back to Decodo, even though the project already uses Decodo for FRED and IMF (same per-IP throttle class).

[OPEN_METEO] 429 for normals batch (Mediterranean, Taiwan Strait); retrying batch in 5s
[OPEN_METEO] 429 for normals batch (Mediterranean, Taiwan Strait); retrying batch in 10s
[OPEN_METEO] 429 for normals batch (Mediterranean, Taiwan Strait); retrying batch in 20s
[OPEN_METEO] 429 for normals batch (Mediterranean, Taiwan Strait); retrying batch in 40s
... (exhausts, throws)

This blocked the PR #3097 bake clock from starting on climate:zone-normals:v1 — the seeder couldn't write the contract envelope even when manually triggered.

Fix

After direct retries exhaust, _open-meteo-archive.mjs falls back to httpsProxyFetchRaw (Decodo) — same pattern as fredFetchJson and imfFetchJson in _seed-utils.mjs.

  • Skips silently if no proxy is configured (preserves existing behavior in non-Railway envs / test runs).
  • Logs [OPEN_METEO] direct exhausted on X; trying proxy so the fallback is visible in Railway logs.
  • Logs [OPEN_METEO] proxy succeeded for X on success and proxy fallback failed for X: <msg> on proxy failure.
  • Non-retryable statuses (500, 502 etc.) now break out of the direct-retry loop instead of throwing immediately, so they too get the proxy attempt before final exhaustion.

Test plan

  • tests/open-meteo-proxy-fallback.test.mjs — 4 new cases (429-no-proxy preserves pre-fix throw, 200 OK passthrough, batch size mismatch detection, non-retryable status flow)
  • npm run test:data5359/5359 pass (+4 new)
  • node --check scripts/_open-meteo-archive.mjs — clean
  • Post-merge: re-trigger seed-bundle-climate in Railway. Expect [OPEN_METEO] direct exhausted on X; trying proxy lines if Open-Meteo still throttles, then proxy succeeded + completed seed run.

Follow-up

Per PR #3097 bake plan: once climate:zone-normals:v1 writes the envelope, /api/seed-contract-probe flips to ok: true and the formal 7-day bake clock starts.

…n Railway IP

Railway logs.1776312819911.log showed seed-climate-zone-normals failing
every batch with HTTP 429 from Open-Meteo's free-tier per-IP throttle
(2026-04-16). The seeder retried with 2/4/8/16s backoff but exhausted
without ever falling back to the project's Decodo proxy infrastructure
that other rate-limited sources (FRED, IMF) already use.

Open-Meteo throttles by source IP. Railway containers share IP pools and
get 429 storms whenever zone-normals fires (monthly cron — high churn
when it runs). Result: PR #3097's bake clock for climate:zone-normals:v1
couldn't start, because the seeder couldn't write the contract envelope
even when manually triggered.

Fix: after direct retries exhaust, _open-meteo-archive.mjs falls back to
httpsProxyFetchRaw (Decodo) — same pattern as fredFetchJson and
imfFetchJson in _seed-utils.mjs. Skips silently if no proxy is configured
(preserves existing behavior in non-Railway envs).

Added tests/open-meteo-proxy-fallback.test.mjs (4 cases):
- 429 with no proxy → throws after exhausting retries (pre-fix behavior preserved)
- 200 OK → returns parsed batch without touching proxy path
- batch size mismatch → throws even on 200
- Non-retryable 500 → break out, attempt proxy, throw exhausted (no extra
  direct retry — matches new control flow)

Verification: npm run test:data → 5359/5359, +4 new. node --check clean.

Same pattern can be applied to any other helper that fetches Open-Meteo
(grep 'open-meteo' scripts/) if more 429s show up.
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 16, 2026 4:27am

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 16, 2026

Greptile Summary

Adds a Decodo proxy fallback to fetchOpenMeteoArchiveBatch after direct retries exhaust, using the same httpsProxyFetchRaw pattern already in place for FRED and IMF. The fix correctly converts the immediate throw to a break for both retryable-status-exhaustion and non-retryable statuses, then attempts the proxy before the final throw.

Two minor gaps worth noting:

  • The catch block for network errors (timeouts, connection resets) still contains a bare throw err on the final attempt, which exits the function without reaching the proxy block — inconsistent with how HTTP-status failures are handled and how imfFetchJson approaches the same pattern.
  • The final thrown error no longer carries the HTTP status code, reducing signal in Railway logs and monitoring.

Confidence Score: 5/5

Safe to merge — the primary 429-exhaustion bug is correctly fixed and all remaining findings are P2 style/robustness suggestions.

The fix targets the specific failure mode (HTTP 429 exhausting all direct retries with no proxy fallback) and the logic is sound. The two flagged items — network errors bypassing the proxy and the loss of HTTP status in the final error message — are pre-existing limitations or minor observability concerns that don't affect correctness.

scripts/_open-meteo-archive.mjs — specifically the catch block's final throw err path (lines 63–71) which still bypasses the proxy.

Important Files Changed

Filename Overview
scripts/_open-meteo-archive.mjs Replaces immediate throw with break+proxy-fallback pattern; core 429-retry logic is sound, but network-error path in the catch block still throws directly and bypasses the proxy.
tests/open-meteo-proxy-fallback.test.mjs Four new tests covering the no-proxy-throw, 200-passthrough, batch-mismatch, and non-retryable-break paths; proxy integration deferred to post-merge Railway validation with clear acknowledgement in comments.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([fetchOpenMeteoArchiveBatch called]) --> B[Build URL with query params]
    B --> C{attempt <= maxRetries?}
    C -- No --> G
    C -- Yes --> D[fetch URL direct]
    D -- network error --> E{attempt < maxRetries?}
    E -- Yes --> F[sleep backoff] --> C
    E -- No --> THROW_NET([throw err ❌ bypasses proxy])
    D -- resp.ok --> OK[parse + validate batch size]
    OK -- mismatch --> THROW_MISMATCH([throw batch size mismatch])
    OK -- ok --> RETURN([return data ✅])
    D -- non-2xx --> H{retryable AND attempt < maxRetries?}
    H -- Yes --> F
    H -- No --> G[break out of loop]
    G --> I{proxyAuth configured?}
    I -- No --> THROW_EX([throw retries exhausted])
    I -- Yes --> J[log direct exhausted; trying proxy]
    J --> K[httpsProxyFetchRaw via Decodo]
    K -- ok --> L[parse + validate batch size]
    L -- mismatch --> M[catch: log proxy failed]
    L -- ok --> N([log proxy succeeded; return data ✅])
    K -- error --> M
    M --> THROW_EX
Loading

Comments Outside Diff (1)

  1. scripts/_open-meteo-archive.mjs, line 63-71 (link)

    P2 Network errors on final retry bypass proxy fallback

    When fetch throws (timeout, ECONNRESET, DNS failure) on the last attempt, the throw err inside the catch block exits the loop and propagates directly, skipping the proxy fallback block below. If Railway IPs were ever TCP-blocked by Open-Meteo rather than receiving HTTP 429s, no proxy attempt would be made despite one being configured.

    The peer helper imfFetchJson avoids this gap by wrapping both network errors and non-2xx responses in a single try/catch that unconditionally falls through to the proxy. Converting the catch path here to break when attempt >= maxRetries would give network errors the same proxy opportunity as HTTP errors:

      } catch (err) {
        if (attempt < maxRetries) {
          const retryMs = retryBaseMs * 2 ** attempt;
          console.log(`  [OPEN_METEO] ${err?.message ?? err} for ${label}; retrying batch in ${Math.round(retryMs / 1000)}s`);
          await sleep(retryMs);
          continue;
        }
        // Final attempt: fall through to proxy, same as HTTP error path
        break;
      }

Reviews (1): Last reviewed commit: "fix(seed-climate-zone-normals): proxy fa..." | Re-trigger Greptile

Comment thread scripts/_open-meteo-archive.mjs Outdated
}
}

throw new Error(`Open-Meteo retries exhausted for ${label}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Final throw drops HTTP status from error message

The original error included the HTTP status (Open-Meteo ${resp.status} for ${label}), which is visible in Railway log tails and in any monitoring system that ingests thrown error messages. The replacement message retries exhausted omits the last-seen status code; the only place the status appears is in the per-retry console.warn lines, which may scroll off or not be correlated. Embedding the last-seen status (tracked as a variable across the loop) in the final throw would preserve the diagnostics without extra complexity.

→ consider tracking lastStatus in the loop body and throwing Open-Meteo retries exhausted (last status ${lastStatus}) for ${label}.

… tests

Addresses two PR #3118 review findings.

P1: catch block did 'throw err' on the final direct attempt, silently
bypassing the proxy fallback for thrown-error cases (timeout, ECONNRESET,
DNS failures). Only non-OK HTTP responses reached the proxy path. Fix:
record the error in lastDirectError and 'break' so control falls through
to the proxy fallback regardless of whether the direct path failed via
thrown error or non-OK status.

Also: include lastDirectError context in the final 'retries exhausted'
message + Error.cause so on-call can see what triggered the fallback
attempt (was: opaque 'retries exhausted').

P2: tests didn't exercise the actual proxy path. Refactored helper to
accept _proxyResolver and _proxyFetcher opt overrides (production
defaults to real resolveProxy/httpsProxyFetchRaw from _seed-utils.mjs;
tests inject mocks). Added 4 new cases:

- 429 + proxy succeeds → returns proxy data
- thrown fetch error on final retry → proxy fallback runs (P1 regression
  guard with explicit assertion: directCalls=2, proxyCalls=1)
- 429 + proxy ALSO fails → throws exhausted, original HTTP 429 in
  message + cause chain
- Proxy returns wrong batch size → caught + warns + throws exhausted

Verification:
- tests/open-meteo-proxy-fallback.test.mjs: 8/8 pass (4 added)
- npm run test:data: 5363/5363 pass (+4 from prior 5359)
- node --check clean
@koala73
Copy link
Copy Markdown
Owner Author

koala73 commented Apr 16, 2026

Addressed all three review findings in ecce579:

P1 (yours)throw err in catch block bypassed proxy fallback for thrown errors (timeout, ECONNRESET, DNS). Fixed: catch now records lastDirectError and breaks, so control falls through to the proxy fallback regardless of whether the direct path failed via thrown error or non-OK status.

P2 (yours) — tests didn't exercise the proxy path. Refactored helper to accept _proxyResolver and _proxyFetcher opt overrides (production defaults to real resolveProxy/httpsProxyFetchRaw from _seed-utils.mjs; tests inject mocks). Added 4 new cases covering: proxy success returns data, thrown-error → proxy runs (P1 regression guard with explicit directCalls=2, proxyCalls=1 assertion), proxy ALSO fails → throws exhausted with original error in cause, proxy returns wrong batch size → caught + warns + throws.

P2 (Greptile) — final throw dropping HTTP status. Final message now includes (last direct: ${lastDirectError.message}) which is HTTP <status> for status-code failures and the original error message for thrown errors, plus Error.cause for chained inspection. Same diagnostic info as before, in a more general form that also covers the thrown-error path.

Tests: 8/8 (was 4); test:data 5363/5363 (+4); typecheck clean.

@koala73 koala73 merged commit 5d1c862 into main Apr 16, 2026
10 checks passed
@koala73 koala73 deleted the fix/open-meteo-proxy-fallback branch April 16, 2026 04:28
koala73 added a commit that referenced this pull request Apr 16, 2026
Decodo's CONNECT egress and curl egress reach DIFFERENT IP pools (per
scripts/_proxy-utils.cjs:67). Probed 2026-04-16 against Yahoo Finance:

  Yahoo via CONNECT (httpsProxyFetchRaw): HTTP 404
  Yahoo via curl (curlFetch):              HTTP 200

For Open-Meteo both paths happen to work today, but pinning the helper
to one path is a single point of failure if Decodo rebalances pools, or
if Open-Meteo starts behaving like Yahoo. PR #3118 wired only the
CONNECT path (`httpsProxyFetchRaw`); this commit adds curl as a
second-choice attempt that runs only when CONNECT also fails.

Cascade:
  direct retries (3) → CONNECT proxy (1) → curl proxy (1) → throw

Steady-state cost: zero. Curl exec only runs when CONNECT also failed.

Final exhausted-throw now appends the LAST proxy error too, so on-call
sees both upstream signals (direct + proxy) instead of just direct.

Tests: added 4 cases locking the cascade behavior:

- CONNECT fails → curl succeeds: returns curl data, neither throws
- CONNECT succeeds: curl never invoked (cost gate)
- CONNECT fails AND curl fails: throws exhausted with both errors
  visible in the message (HTTP 429 from direct + curl 502 from proxy)
- curl returns malformed JSON: caught + warns + throws exhausted

Updated 2 existing tests to also stub _proxyCurlFetcher so they don't
shell out to real curl when CONNECT is mocked-failed (would have run
real curl with proxy.test:8000 → 8s timeout per test).

Verification:
- tests/open-meteo-proxy-fallback.test.mjs → 12/12 pass (was 8, +4 new)
- npm run test:data → 5367/5367 (+4)
- npm run typecheck:all → clean

Followup to PR #3118.
koala73 added a commit that referenced this pull request Apr 16, 2026
Yahoo Finance throttles Railway egress IPs aggressively. 4 seeders
(seed-commodity-quotes, seed-etf-flows, seed-gulf-quotes, seed-market-quotes)
duplicated the same fetchYahooWithRetry block with no proxy fallback.
This helper consolidates them and adds the proxy fallback.

Yahoo-specific: CURL-ONLY proxy strategy. Probed 2026-04-16:
  query1.finance.yahoo.com via CONNECT (httpsProxyFetchRaw): HTTP 404
  query1.finance.yahoo.com via curl    (curlFetch):          HTTP 200
Yahoo's edge blocks Decodo's CONNECT egress IPs but accepts the curl
egress IPs. Helper deliberately omits the CONNECT leg — adding it
would burn time on guaranteed-404 attempts. Production defaults expose
ONLY curlProxyResolver + curlFetcher.

All learnings from PR #3118 + #3119 reviews baked in:
- lastDirectError accumulator across the loop, embedded in final throw +
  Error.cause chain
- catch block uses break (NOT throw) so thrown errors also reach proxy
- DI seams (_curlProxyResolver, _proxyCurlFetcher) for hermetic tests
- _PROXY_DEFAULTS exported for production-default lock tests
- Sync curlFetch wrapped with await Promise.resolve() to future-proof
  against an async refactor (Greptile P2 from #3119)

Tests (tests/yahoo-fetch.test.mjs, 11 cases):
- Production defaults: curl resolver/fetcher reference equality
- Production defaults: NO CONNECT leg present (regression guard)
- 200 OK passthrough, never touches proxy
- 429 with no proxy → throws exhausted with HTTP 429 in message
- Retry-After header parsed correctly
- 429 + curl proxy succeeds → returns proxy data
- Thrown fetch error on final retry → proxy fallback runs (P1 guard)
- 429 + proxy ALSO fails → both errors visible in message + cause chain
- Proxy malformed JSON → throws exhausted
- Non-retryable 500 → no extra direct retry, falls to proxy
- parseRetryAfterMs unit (exported sanity check)

Verification: 11/11 helper tests pass. node --check clean.

Phase 1 of 2 — seeder migrations follow.
koala73 added a commit that referenced this pull request Apr 16, 2026
…#3119)

* fix(open-meteo): curl proxy as second-choice when CONNECT proxy fails

Decodo's CONNECT egress and curl egress reach DIFFERENT IP pools (per
scripts/_proxy-utils.cjs:67). Probed 2026-04-16 against Yahoo Finance:

  Yahoo via CONNECT (httpsProxyFetchRaw): HTTP 404
  Yahoo via curl (curlFetch):              HTTP 200

For Open-Meteo both paths happen to work today, but pinning the helper
to one path is a single point of failure if Decodo rebalances pools, or
if Open-Meteo starts behaving like Yahoo. PR #3118 wired only the
CONNECT path (`httpsProxyFetchRaw`); this commit adds curl as a
second-choice attempt that runs only when CONNECT also fails.

Cascade:
  direct retries (3) → CONNECT proxy (1) → curl proxy (1) → throw

Steady-state cost: zero. Curl exec only runs when CONNECT also failed.

Final exhausted-throw now appends the LAST proxy error too, so on-call
sees both upstream signals (direct + proxy) instead of just direct.

Tests: added 4 cases locking the cascade behavior:

- CONNECT fails → curl succeeds: returns curl data, neither throws
- CONNECT succeeds: curl never invoked (cost gate)
- CONNECT fails AND curl fails: throws exhausted with both errors
  visible in the message (HTTP 429 from direct + curl 502 from proxy)
- curl returns malformed JSON: caught + warns + throws exhausted

Updated 2 existing tests to also stub _proxyCurlFetcher so they don't
shell out to real curl when CONNECT is mocked-failed (would have run
real curl with proxy.test:8000 → 8s timeout per test).

Verification:
- tests/open-meteo-proxy-fallback.test.mjs → 12/12 pass (was 8, +4 new)
- npm run test:data → 5367/5367 (+4)
- npm run typecheck:all → clean

Followup to PR #3118.

* fix: CONNECT leg uses resolveProxyForConnect; lock production defaults

P1 from PR #3119 review: the cascade was logged as 'CONNECT proxy → curl
proxy' but BOTH legs were resolving via resolveProxy() — which rewrites
gate.decodo.com → us.decodo.com for curl egress. So the 'two-leg
cascade' was actually one Decodo egress pool wearing two transport
mechanisms. Defeats the redundancy this PR is supposed to provide.

Fix: import resolveProxyForConnect (preserves gate.decodo.com — the
host Decodo routes via its CONNECT egress pool, distinct from the
curl-egress pool reached by us.decodo.com via resolveProxy). CONNECT
leg uses resolveProxyForConnect; curl leg uses resolveProxy. Matches
the established pattern in scripts/seed-portwatch-chokepoints-ref.mjs:33-37
and scripts/seed-recovery-external-debt.mjs:31-35.

Refactored test seams: split single _proxyResolver into
_connectProxyResolver + _curlProxyResolver. Test files inject both.

P2 fix: every cascade test injected _proxyResolver, so the suite stayed
green even when production defaults were misconfigured. Exported
_PROXY_DEFAULTS object and added 2 lock-tests:

  1. CONNECT leg uses resolveProxyForConnect, curl leg uses resolveProxy
     (reference equality on each of 4 default fields).
  2. connect/curl resolvers are different functions — guards against the
     'collapsed cascade' regression class generally, not just this
     specific instance.

Updated the 8 existing cascade tests to inject BOTH resolvers. The
docstring at the top of the file now spells out the wiring invariant
and points to the lock-tests.

Verification:
- tests/open-meteo-proxy-fallback.test.mjs: 14/14 pass (+2)
- npm run test:data: 5369/5369 (+2)
- npm run typecheck:all: clean

Followup commit on PR #3119.

* fix(open-meteo): future-proof sync curlFetch call with Promise.resolve+await

Greptile P2: _proxyCurlFetcher (curlFetch / execFileSync) is sync today,
adjacent CONNECT path is async (await _proxyFetcher(...)). A future
refactor of curlFetch to async would silently break this line — JSON.parse
would receive a Promise<string> instead of a string and explode at parse
time, not at the obvious call site.

Wrapping with await Promise.resolve(...) is a no-op for the current sync
implementation but auto-handles a future async refactor. Comment spells
out the contract so the wrap doesn't read as cargo-cult.

Tests still 14/14.
koala73 added a commit that referenced this pull request Apr 16, 2026
… + 4 seeder migrations (#3120)

* feat(_yahoo-fetch): curl-only Decodo proxy fallback helper

Yahoo Finance throttles Railway egress IPs aggressively. 4 seeders
(seed-commodity-quotes, seed-etf-flows, seed-gulf-quotes, seed-market-quotes)
duplicated the same fetchYahooWithRetry block with no proxy fallback.
This helper consolidates them and adds the proxy fallback.

Yahoo-specific: CURL-ONLY proxy strategy. Probed 2026-04-16:
  query1.finance.yahoo.com via CONNECT (httpsProxyFetchRaw): HTTP 404
  query1.finance.yahoo.com via curl    (curlFetch):          HTTP 200
Yahoo's edge blocks Decodo's CONNECT egress IPs but accepts the curl
egress IPs. Helper deliberately omits the CONNECT leg — adding it
would burn time on guaranteed-404 attempts. Production defaults expose
ONLY curlProxyResolver + curlFetcher.

All learnings from PR #3118 + #3119 reviews baked in:
- lastDirectError accumulator across the loop, embedded in final throw +
  Error.cause chain
- catch block uses break (NOT throw) so thrown errors also reach proxy
- DI seams (_curlProxyResolver, _proxyCurlFetcher) for hermetic tests
- _PROXY_DEFAULTS exported for production-default lock tests
- Sync curlFetch wrapped with await Promise.resolve() to future-proof
  against an async refactor (Greptile P2 from #3119)

Tests (tests/yahoo-fetch.test.mjs, 11 cases):
- Production defaults: curl resolver/fetcher reference equality
- Production defaults: NO CONNECT leg present (regression guard)
- 200 OK passthrough, never touches proxy
- 429 with no proxy → throws exhausted with HTTP 429 in message
- Retry-After header parsed correctly
- 429 + curl proxy succeeds → returns proxy data
- Thrown fetch error on final retry → proxy fallback runs (P1 guard)
- 429 + proxy ALSO fails → both errors visible in message + cause chain
- Proxy malformed JSON → throws exhausted
- Non-retryable 500 → no extra direct retry, falls to proxy
- parseRetryAfterMs unit (exported sanity check)

Verification: 11/11 helper tests pass. node --check clean.

Phase 1 of 2 — seeder migrations follow.

* feat(yahoo-seeders): migrate 4 seeders to _yahoo-fetch helper

Removes the duplicated fetchYahooWithRetry function (4 byte-identical
copies across seed-commodity-quotes, seed-etf-flows, seed-gulf-quotes,
seed-market-quotes) and routes all Yahoo Finance fetches through the
new scripts/_yahoo-fetch.mjs helper. Each seeder gains the curl-only
Decodo proxy fallback baked into the helper.

Per-seeder changes (mechanical):
- import { fetchYahooJson } from './_yahoo-fetch.mjs'
- delete the local fetchYahooWithRetry function
- replace 'const resp = await fetchYahooWithRetry(url, label); if (!resp)
  return X; const json = await resp.json()' with
  'let json; try { json = await fetchYahooJson(url, { label }); }
  catch { return X; }'
- prune now-unused CHROME_UA/sleep imports where applicable

Latent bugs fixed in passing:
- seed-etf-flows.mjs:23 and seed-market-quotes.mjs:38 referenced
  CHROME_UA without importing it (would throw ReferenceError at
  runtime if the helper were called). Now the call site is gone in
  etf-flows; in market-quotes CHROME_UA is properly imported because
  Finnhub call still uses it.

seed-commodity-quotes also has fetchYahooChart1y (separate non-retry
function for gold history). Migrated to use fetchYahooJson under the
hood — preserves return shape, adds proxy fallback automatically.

Verification:
- node --check clean on all 4 modified seeders
- npm run typecheck:all clean
- npm run test:data: 5374/5374 pass

Phase 2 of 2.

* fix(_yahoo-fetch): log success AFTER parse + add _sleep DI seam for honest Retry-After test

Greptile P2: "[YAHOO] proxy (curl) succeeded" was logged BEFORE
JSON.parse(text). On malformed proxy JSON, Railway logs would show:

  [YAHOO] proxy (curl) succeeded for AAPL
  throw: Yahoo retries exhausted ...

Contradictory + breaks the post-deploy log-grep verification this PR
relies on ("look for [YAHOO] proxy (curl) succeeded"). Fix: parse
first; success log only fires when parse succeeds AND the value is
about to be returned.

Greptile P3: 'Retry-After header parsed correctly' test used header
value '0', but parseRetryAfterMs() treats non-positive seconds as null
→ helper falls through to default linear backoff. So the test was
exercising the wrong branch despite its name.

Fix: added _sleep DI opt seam to the helper. New test injects a sleep
spy and asserts the captured duration:

  Retry-After: '7' → captured sleep == [7000]   (Retry-After branch)
  no Retry-After  → captured sleep == [10]      (default backoff = retryBaseMs * 1)

Two paired tests lock both branches separately so a future regression
that collapses them is caught.

Also added a log-ordering regression test: malformed proxy JSON must
NOT emit the 'succeeded' log. Captures console.log into an array and
asserts no 'proxy (curl) succeeded' line appeared before the throw.

Verification:
- tests/yahoo-fetch.test.mjs: 13/13 (was 11, +2)
- npm run test:data: 5376/5376 (+2)
- npm run typecheck:all: clean

Followup commits on PR #3120.
koala73 added a commit that referenced this pull request Apr 16, 2026
…d API

GDELT (api.gdeltproject.org) is a public free API with strict per-IP
throttling. seed-gdelt-intel currently has no proxy fallback — Railway
egress IPs hit 429 storms and the seeder degrades.

Probed 2026-04-16: Decodo curl egress against GDELT gives ~40% success
per attempt (session-rotates IPs per call). Helper retries up to 5×;
expected overall success ~92% (1 - 0.6^5).

PROXY STRATEGY — CURL ONLY WITH MULTI-RETRY

Differs from _yahoo-fetch.mjs (single proxy attempt) and
_open-meteo-archive.mjs (CONNECT + curl cascade):
- Curl-only: CONNECT not yet probed cleanly against GDELT.
- Multi-retry on the curl leg: the proxy IS the rotation mechanism
  (each call → different egress IP), so successive attempts probe
  different IPs in the throttle pool.
- Distinguishes retryable (HTTP 429/503 from upstream) from
  non-retryable (parse failure, auth, network) — bails immediately on
  non-retryable to avoid 5× of wasted log noise.

Direct loop uses LONGER backoff than Yahoo's 5s base (10s) — GDELT's
throttle window is wider than Yahoo's, so quick retries usually re-hit
the same throttle.

Tests (tests/gdelt-fetch.test.mjs, 13 cases — every learning from
PR #3118 + #3119 + #3120 baked in):

- Production defaults: curl resolver/fetcher reference equality
- Production defaults: NO CONNECT leg (regression guard for unverified path)
- 200 OK passthrough
- 429 with no proxy → throws with HTTP 429 in message
- Retry-After parsed (DI _sleep capture asserts 7000ms not retryBaseMs)
- Retry-After absent → linear backoff retryBaseMs (paired branch test)
- **Proxy multi-retry: 4× HTTP 429 then 5th succeeds → returns data**
  (asserts 5 proxy calls + 4 inter-proxy backoffs of proxyRetryBaseMs)
- **Proxy non-retryable (parse failure) bails after 1 attempt**
  (does NOT burn all proxyMaxAttempts on a structural failure)
- **Proxy retryable + non-retryable mix: retries on 429, bails on parse**
- Thrown fetch error on final retry → proxy multi-retry runs (P1 guard)
- All proxy attempts fail → throws with 'X/N attempts' in message + cause
- Malformed JSON does NOT emit succeeded log before throw (P2 guard)
- parseRetryAfterMs unit

Verification:
- tests/gdelt-fetch.test.mjs → 13/13 pass
- node --check scripts/_gdelt-fetch.mjs → clean

Phase 1 of 2. Seeder migration follows.
koala73 added a commit that referenced this pull request Apr 16, 2026
…delt-intel migration (#3122)

* feat(_gdelt-fetch): curl proxy multi-retry helper for per-IP-throttled API

GDELT (api.gdeltproject.org) is a public free API with strict per-IP
throttling. seed-gdelt-intel currently has no proxy fallback — Railway
egress IPs hit 429 storms and the seeder degrades.

Probed 2026-04-16: Decodo curl egress against GDELT gives ~40% success
per attempt (session-rotates IPs per call). Helper retries up to 5×;
expected overall success ~92% (1 - 0.6^5).

PROXY STRATEGY — CURL ONLY WITH MULTI-RETRY

Differs from _yahoo-fetch.mjs (single proxy attempt) and
_open-meteo-archive.mjs (CONNECT + curl cascade):
- Curl-only: CONNECT not yet probed cleanly against GDELT.
- Multi-retry on the curl leg: the proxy IS the rotation mechanism
  (each call → different egress IP), so successive attempts probe
  different IPs in the throttle pool.
- Distinguishes retryable (HTTP 429/503 from upstream) from
  non-retryable (parse failure, auth, network) — bails immediately on
  non-retryable to avoid 5× of wasted log noise.

Direct loop uses LONGER backoff than Yahoo's 5s base (10s) — GDELT's
throttle window is wider than Yahoo's, so quick retries usually re-hit
the same throttle.

Tests (tests/gdelt-fetch.test.mjs, 13 cases — every learning from
PR #3118 + #3119 + #3120 baked in):

- Production defaults: curl resolver/fetcher reference equality
- Production defaults: NO CONNECT leg (regression guard for unverified path)
- 200 OK passthrough
- 429 with no proxy → throws with HTTP 429 in message
- Retry-After parsed (DI _sleep capture asserts 7000ms not retryBaseMs)
- Retry-After absent → linear backoff retryBaseMs (paired branch test)
- **Proxy multi-retry: 4× HTTP 429 then 5th succeeds → returns data**
  (asserts 5 proxy calls + 4 inter-proxy backoffs of proxyRetryBaseMs)
- **Proxy non-retryable (parse failure) bails after 1 attempt**
  (does NOT burn all proxyMaxAttempts on a structural failure)
- **Proxy retryable + non-retryable mix: retries on 429, bails on parse**
- Thrown fetch error on final retry → proxy multi-retry runs (P1 guard)
- All proxy attempts fail → throws with 'X/N attempts' in message + cause
- Malformed JSON does NOT emit succeeded log before throw (P2 guard)
- parseRetryAfterMs unit

Verification:
- tests/gdelt-fetch.test.mjs → 13/13 pass
- node --check scripts/_gdelt-fetch.mjs → clean

Phase 1 of 2. Seeder migration follows.

* feat(seed-gdelt-intel): migrate to _gdelt-fetch helper

Replaces direct fetch + ad-hoc retry in seed-gdelt-intel with the new
fetchGdeltJson helper. Each topic call now gets:
  3 direct retries (10/20/40s backoff) → 5 curl proxy attempts via
  Decodo session-rotating egress.

Specific changes:
- import fetchGdeltJson from _gdelt-fetch.mjs
- fetchTopicArticles: replace fetch+retry+throw block with single
  await fetchGdeltJson(url, { label: topic.id })
- fetchTopicTimeline: same — best-effort try/catch returns [] on any
  failure (preserved). Helper still attempts proxy fallback before
  throwing, so a 429-throttled IP doesn't kill the timeline.
- fetchWithRetry: collapsed from outer 3-retry loop with 60/120/240s
  backoff (which would have multiplied to 24 attempts/topic on top of
  helper's 8) to a thin wrapper that translates exhaustion into the
  {exhausted, articles:[]} shape the caller uses to drive
  POST_EXHAUST_DELAY_MS cooldown.
- Drop CHROME_UA import (no longer used directly; helper handles it).

Helper's exhausted-throw includes 'HTTP 429' substring when 429 was
the upstream signal, so the existing is429 detection in
fetchWithRetry continues to work without modification.

Verification:
- node --check scripts/seed-gdelt-intel.mjs → clean
- npm run typecheck:all → clean
- npm run test:data → 5382/5382 (was 5363, +13 from helper + 6 from
  prior PR work)

Phase 2 of 2.

* fix(_gdelt-fetch): proxy timeouts/network errors RETRY (rotates Decodo session)

P1 from PR #3122 review: probed Decodo curl egress against GDELT
(2026-04-16) gave 200/200/429/TIMEOUT/429 — TIMEOUT is part of the
normal transient mix that the multi-retry design exists to absorb.
Pre-fix logic only retried on substring 'HTTP 429'/'HTTP 503' matches,
so a curl exec timeout (Node Error with no .status, not a SyntaxError)
bailed on the first attempt. The PR's headline 'expected ~92% success
with 5 attempts' was therefore not actually achievable for one of the
exact failure modes that motivated the design.

Reframed the proxy retryability decision around what we CAN reliably
discriminate from the curl error shape:

  curlErr.status == number    → retry only if 429/503
                                (curlFetch attaches .status only when
                                 curl returned a clean HTTP status)
  curlErr instanceof SyntaxError → bail (parse failure is structural)
  otherwise                   → RETRY (timeout, ECONNRESET, DNS, curl
                                 exec failure, CONNECT tunnel failure
                                 — all transient; rotating Decodo
                                 session usually clears them)

P2 from same review: tests covered HTTP-status proxy retries + parse
failures but never the timeout/thrown-error class. Added 3 tests:

- proxy timeout (no .status) RETRIES → asserts proxyCalls=2 after a
  first-attempt ETIMEDOUT then second-attempt success
- proxy ECONNRESET (no .status) RETRIES → same pattern
- proxy HTTP 4xx with .status (e.g. 401 auth) does NOT retry → bails
  after 1 attempt

Existing tests still pass — they use 'HTTP 429' Error WITHOUT .status,
which now flows through the 'else: assume transient' branch and still
retries. Only differences: the regex parsing is gone and curlFetch's
.status property is the canonical signal.

Verification:
- tests/gdelt-fetch.test.mjs: 16/16 (was 13, +3)
- npm run test:data: 5385/5385 (+3)
- npm run typecheck:all: clean

Followup commit on PR #3122.

* fix(seed-gdelt-intel): timeline calls fast-fail (maxRetries:0, proxyMaxAttempts:0)

P1 from PR #3122 review: fetchTopicTimeline is best-effort (returns []
on any failure), but the migration routed it through fetchGdeltJson
with the helper's article-fetch defaults: 3 direct retries (10/20/40s
backoff = ~70s) + 5 proxy attempts (5s base = ~20s) = ~90s worst case
per call. Called 2× per topic × 6 topics = 12 calls = up to ~18 minutes
of blocking on data the seeder discards on failure. Pre-helper code
did a single direct fetch with no retry.

Real operational regression under exactly the GDELT 429 storm conditions
this PR is meant to absorb.

Fix:

1. seed-gdelt-intel.mjs:fetchTopicTimeline now passes
   maxRetries:0, proxyMaxAttempts:0 — single direct attempt, no proxy,
   throws on first failure → caught, returns []. Matches pre-helper
   timing exactly. Article fetches keep the full retry budget; only
   timelines fast-fail.

2. _gdelt-fetch.mjs gate: skip the proxy block entirely when
   proxyMaxAttempts <= 0. Pre-fix the 'trying proxy (curl) up to 0×'
   log line would still emit even though the for loop runs zero times,
   producing a misleading line that the proxy was attempted when it
   wasn't.

Tests (2 new):

- maxRetries:0 + proxyMaxAttempts:0 → asserts directCalls=1,
  proxyCalls=0 even though _curlProxyResolver returns a valid auth
  string (proxy block must be fully bypassed).
- proxyMaxAttempts:0 → captures console.log and asserts no 'trying
  proxy' line emitted (no misleading 'up to 0×' line).

Verification:
- tests/gdelt-fetch.test.mjs: 18/18 (was 16, +2)
- npm run test:data: 5387/5387 (+2)
- npm run typecheck:all: clean

Followup commit on PR #3122.

* fix(gdelt): direct parse-failure reaches proxy + timeline budget tweak + JSDoc accuracy

3 Greptile P2s on PR #3122:

P2a — _gdelt-fetch.mjs:112: `resp.json()` was called outside the
try/catch that guards fetch(). A 200 OK with HTML/garbage body (WAF
challenge, partial response, gzip mismatch) would throw SyntaxError
and escape the helper entirely — proxy fallback never ran. The proxy
leg already parsed inside its own catch; making the direct leg
symmetric. New regression test: direct 200 OK with malformed JSON
must reach the proxy and recover.

P2b — seed-gdelt-intel.mjs timeline budget bumped from 0/0 to 0/2.
Best-effort timelines still fast-fail on direct 429 (no direct
retries) but get 2 proxy attempts via Decodo session rotation before
returning []. Worst case: ~25s/call × 12 calls = ~5 min ceiling under
heavy throttling vs ~3 min with 0/0. Tradeoff: small additional time
budget for a real chance to recover timeline data via proxy IP rotation.
Articles still keep the full retry budget.

P2c — JSDoc said 'Linear proxy backoff base' but the implementation
uses a flat constant (proxyRetryBaseMs, line 156). Linear growth
would not help here because Decodo rotates the session IP per call —
the next attempt's success is independent of the previous wait. Doc
now reads 'Fixed (constant, NOT linear) backoff' with the rationale.

Verification:
- tests/gdelt-fetch.test.mjs: 19/19 pass (was 18, +1)
- npm run test:data: 5388/5388 (+1)
- npm run typecheck:all: clean

Followup commit on PR #3122.

* test(gdelt): clarify helper-API vs seeder-mirror tests + add 0/2 lock

Reviewer feedback on PR #3122 conflated two test classes:
- Helper-API tests (lock the helper's contract for arbitrary callers
  using budget knobs like 0/0 — independent of any specific seeder)
- Seeder-mirror tests (lock the budget the actual production caller
  in seed-gdelt-intel.mjs uses)

Pre-fix the test file only had the 0/0 helper-API tests, with a
section header that read 'Best-effort caller budgets (fast-fail)' —
ambiguous about whether 0/0 was the helper API contract or the
seeder's choice. Reviewer assumed seeder still used 0/0 because the
tests locked it, but seed-gdelt-intel.mjs:97-98 actually uses 0/2
(per the prior P2b fix).

Fixes:

1. Section header for the 0/0 tests now explicitly says these are
   helper-API tests and notes that seed-gdelt-intel uses 0/2 (not
   0/0). Eliminates the conflation.

2. New 'Seeder-mirror: 0/2' section with 2 tests that lock the
   seeder's actual choice end-to-end:

   - 0/2 with first proxy attempt 429 + second succeeds → returns
     data (asserts directCalls=1, proxyCalls=2)
   - 0/2 with both proxy attempts failing → throws exhausted with
     '2/2 attempts' in message (asserts the budget propagates to the
     error message correctly)

   These tests would catch any future regression where the seeder's
   0/2 choice gets reverted to 0/0 OR where the helper stops
   honoring the proxyMaxAttempts override.

Verification:
- tests/gdelt-fetch.test.mjs: 21/21 (was 19, +2)
- npm run test:data: 5390/5390 (+2)
- npm run typecheck:all: clean

Followup commit on PR #3122.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant