Skip to content

Commit 5d1c862

Browse files
authored
fix(seed-climate-zone-normals): proxy fallback when Open-Meteo 429s on Railway IP (#3118)
* fix(seed-climate-zone-normals): proxy fallback when Open-Meteo 429s on 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. * fix: proxy fallback runs on thrown direct errors + actually-exercised 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
1 parent e6a6d4e commit 5d1c862

2 files changed

Lines changed: 289 additions & 4 deletions

File tree

scripts/_open-meteo-archive.mjs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CHROME_UA, sleep } from './_seed-utils.mjs';
1+
import { CHROME_UA, sleep, resolveProxy, httpsProxyFetchRaw } from './_seed-utils.mjs';
22

33
const MAX_RETRY_AFTER_MS = 60_000;
44
const RETRYABLE_STATUSES = new Set([429, 503]);
@@ -41,6 +41,13 @@ export async function fetchOpenMeteoArchiveBatch(zones, opts) {
4141
maxRetries = 3,
4242
retryBaseMs = 2_000,
4343
label = zones.map((zone) => zone.name).join(', '),
44+
// Test hooks. Production callers leave these unset; the helper uses the
45+
// real proxy resolver + fetcher from _seed-utils.mjs. Tests inject mocks
46+
// here to exercise the proxy fallback path without spinning up a real
47+
// Decodo tunnel. Keep these undocumented in PR descriptions — they are
48+
// implementation-only seams, not a public API surface.
49+
_proxyResolver = resolveProxy,
50+
_proxyFetcher = httpsProxyFetchRaw,
4451
} = opts;
4552

4653
const params = new URLSearchParams({
@@ -53,6 +60,13 @@ export async function fetchOpenMeteoArchiveBatch(zones, opts) {
5360
});
5461
const url = `https://archive-api.open-meteo.com/v1/archive?${params.toString()}`;
5562

63+
// Track the last direct-path failure so the eventual throw carries useful
64+
// context if proxy fallback is also unavailable / fails. Without this the
65+
// helper would throw a generic "retries exhausted" message and lose the
66+
// upstream error (timeout, ECONNRESET, HTTP status code) that triggered
67+
// the fallback path.
68+
let lastDirectError = null;
69+
5670
for (let attempt = 0; attempt <= maxRetries; attempt++) {
5771
let resp;
5872
try {
@@ -61,13 +75,18 @@ export async function fetchOpenMeteoArchiveBatch(zones, opts) {
6175
signal: AbortSignal.timeout(timeoutMs),
6276
});
6377
} catch (err) {
78+
lastDirectError = err;
6479
if (attempt < maxRetries) {
6580
const retryMs = retryBaseMs * 2 ** attempt;
6681
console.log(` [OPEN_METEO] ${err?.message ?? err} for ${label}; retrying batch in ${Math.round(retryMs / 1000)}s`);
6782
await sleep(retryMs);
6883
continue;
6984
}
70-
throw err;
85+
// Final direct attempt threw (timeout, ECONNRESET, DNS, etc.). Fall
86+
// through to the proxy fallback below — the previous version threw
87+
// here, which silently bypassed the proxy path for thrown-error cases
88+
// and only ran fallback for non-OK HTTP responses.
89+
break;
7190
}
7291

7392
if (resp.ok) {
@@ -78,15 +97,48 @@ export async function fetchOpenMeteoArchiveBatch(zones, opts) {
7897
return data;
7998
}
8099

100+
lastDirectError = new Error(`HTTP ${resp.status}`);
101+
81102
if (RETRYABLE_STATUSES.has(resp.status) && attempt < maxRetries) {
82103
const retryMs = parseRetryAfterMs(resp.headers.get('retry-after')) ?? (retryBaseMs * 2 ** attempt);
83104
console.log(` [OPEN_METEO] ${resp.status} for ${label}; retrying batch in ${Math.round(retryMs / 1000)}s`);
84105
await sleep(retryMs);
85106
continue;
86107
}
87108

88-
throw new Error(`Open-Meteo ${resp.status} for ${label}`);
109+
// Direct attempt failed with non-retryable or after-final-retry status.
110+
// Open-Meteo's free tier rate-limits per source IP; Railway containers
111+
// share IP pools and hit 429 storms (logs.1776312819911 — every batch
112+
// 429'd through 4 retries on 2026-04-16). Fall through to proxy fallback
113+
// below before throwing.
114+
break;
115+
}
116+
117+
// Proxy fallback — same pattern as fredFetchJson / imfFetchJson in
118+
// _seed-utils.mjs. Decodo gateway gets a different egress IP that is not
119+
// (yet) on Open-Meteo's per-IP throttle. Skip silently if no proxy is
120+
// configured (preserves existing behavior in non-Railway envs).
121+
const proxyAuth = _proxyResolver();
122+
if (proxyAuth) {
123+
try {
124+
console.log(` [OPEN_METEO] direct exhausted on ${label} (${lastDirectError?.message ?? 'unknown'}); trying proxy`);
125+
const { buffer } = await _proxyFetcher(url, proxyAuth, {
126+
accept: 'application/json',
127+
timeoutMs,
128+
});
129+
const data = normalizeArchiveBatchResponse(JSON.parse(buffer.toString('utf8')));
130+
if (data.length !== zones.length) {
131+
throw new Error(`Open-Meteo proxy batch size mismatch for ${label}: expected ${zones.length}, got ${data.length}`);
132+
}
133+
console.log(` [OPEN_METEO] proxy succeeded for ${label}`);
134+
return data;
135+
} catch (proxyErr) {
136+
console.warn(` [OPEN_METEO] proxy fallback failed for ${label}: ${proxyErr?.message ?? proxyErr}`);
137+
}
89138
}
90139

91-
throw new Error(`Open-Meteo retries exhausted for ${label}`);
140+
throw new Error(
141+
`Open-Meteo retries exhausted for ${label}${lastDirectError ? ` (last direct: ${lastDirectError.message})` : ''}`,
142+
lastDirectError ? { cause: lastDirectError } : undefined,
143+
);
92144
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Locks the proxy-fallback behavior added to _open-meteo-archive.mjs after
2+
// Railway 2026-04-16 logs showed seed-climate-zone-normals failing every
3+
// batch with HTTP 429 from Open-Meteo's per-IP free-tier throttle, with no
4+
// proxy retry.
5+
//
6+
// All HTTP is mocked — no real fetch / Decodo calls.
7+
8+
import { test, beforeEach, afterEach } from 'node:test';
9+
import { strict as assert } from 'node:assert';
10+
11+
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.test';
12+
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token';
13+
14+
const ZONES = [
15+
{ name: 'Tropical', lat: 0, lon: 0 },
16+
{ name: 'Polar', lat: 80, lon: 0 },
17+
];
18+
19+
const VALID_PAYLOAD = ZONES.map((z) => ({
20+
latitude: z.lat,
21+
longitude: z.lon,
22+
daily: { time: ['2020-01-01'], temperature_2m_mean: [10] },
23+
}));
24+
25+
const ARCHIVE_OPTS = {
26+
startDate: '2020-01-01',
27+
endDate: '2020-01-02',
28+
daily: ['temperature_2m_mean'],
29+
maxRetries: 1,
30+
retryBaseMs: 10,
31+
timeoutMs: 1000,
32+
};
33+
34+
const originalFetch = globalThis.fetch;
35+
let capturedProxyCalls;
36+
37+
beforeEach(() => {
38+
capturedProxyCalls = [];
39+
});
40+
41+
afterEach(() => {
42+
globalThis.fetch = originalFetch;
43+
delete process.env.PROXY_USER;
44+
delete process.env.PROXY_PASS;
45+
delete process.env.PROXY_HOST;
46+
delete process.env.PROXY_PORT;
47+
delete process.env.SEED_PROXY_AUTH;
48+
});
49+
50+
// The helper accepts `_proxyResolver` and `_proxyFetcher` opt overrides
51+
// specifically for tests — production callers leave them unset and get the
52+
// real Decodo path from _seed-utils.mjs. This lets us exercise the proxy
53+
// branch without spinning up a real CONNECT tunnel.
54+
55+
test('429 with no proxy configured: throws after exhausting retries (preserves pre-fix behavior)', async () => {
56+
// Re-import per-test so module-level state (none currently) is fresh.
57+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
58+
59+
let calls = 0;
60+
globalThis.fetch = async () => {
61+
calls += 1;
62+
return {
63+
ok: false, status: 429,
64+
headers: { get: () => null },
65+
json: async () => ({}),
66+
};
67+
};
68+
69+
await assert.rejects(
70+
() => fetchOpenMeteoArchiveBatch(ZONES, ARCHIVE_OPTS),
71+
/Open-Meteo retries exhausted/,
72+
);
73+
// 1 initial + 1 retry (maxRetries=1) = 2 direct calls
74+
assert.equal(calls, 2);
75+
});
76+
77+
test('200 OK: returns parsed batch without touching proxy path', async () => {
78+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
79+
80+
globalThis.fetch = async () => ({
81+
ok: true, status: 200,
82+
headers: { get: () => null },
83+
json: async () => VALID_PAYLOAD,
84+
});
85+
86+
const result = await fetchOpenMeteoArchiveBatch(ZONES, ARCHIVE_OPTS);
87+
assert.equal(result.length, 2);
88+
assert.equal(result[0].latitude, 0);
89+
});
90+
91+
test('batch size mismatch: throws even on 200', async () => {
92+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
93+
globalThis.fetch = async () => ({
94+
ok: true, status: 200,
95+
headers: { get: () => null },
96+
json: async () => [VALID_PAYLOAD[0]], // only 1, not 2
97+
});
98+
await assert.rejects(
99+
() => fetchOpenMeteoArchiveBatch(ZONES, ARCHIVE_OPTS),
100+
/batch size mismatch/,
101+
);
102+
});
103+
104+
test('non-retryable status (500): falls through to proxy attempt without extra retry', async () => {
105+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
106+
107+
let calls = 0;
108+
globalThis.fetch = async () => {
109+
calls += 1;
110+
return {
111+
ok: false, status: 500,
112+
headers: { get: () => null },
113+
json: async () => ({}),
114+
};
115+
};
116+
117+
await assert.rejects(
118+
() => fetchOpenMeteoArchiveBatch(ZONES, ARCHIVE_OPTS),
119+
/Open-Meteo retries exhausted/,
120+
);
121+
// Non-retryable status: no further retries — break out of the loop after
122+
// first attempt, then the proxy-fallback block runs (no proxy env →
123+
// skipped) → throws exhausted.
124+
assert.equal(calls, 1);
125+
});
126+
127+
// ─── Proxy fallback path — actually exercised via _proxyResolver/_proxyFetcher ───
128+
129+
test('429 + proxy configured + proxy succeeds: returns proxy data, never throws', async () => {
130+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
131+
132+
globalThis.fetch = async () => ({
133+
ok: false, status: 429,
134+
headers: { get: () => null },
135+
json: async () => ({}),
136+
});
137+
138+
let proxyCalls = 0;
139+
let receivedProxyAuth = null;
140+
const result = await fetchOpenMeteoArchiveBatch(ZONES, {
141+
...ARCHIVE_OPTS,
142+
_proxyResolver: () => 'user:pass@gate.decodo.com:7000',
143+
_proxyFetcher: async (url, proxyAuth, _opts) => {
144+
proxyCalls += 1;
145+
receivedProxyAuth = proxyAuth;
146+
assert.match(url, /archive-api\.open-meteo\.com\/v1\/archive\?/);
147+
return { buffer: Buffer.from(JSON.stringify(VALID_PAYLOAD), 'utf8'), contentType: 'application/json' };
148+
},
149+
});
150+
151+
assert.equal(proxyCalls, 1);
152+
assert.equal(receivedProxyAuth, 'user:pass@gate.decodo.com:7000');
153+
assert.equal(result.length, 2);
154+
assert.equal(result[1].latitude, 80);
155+
});
156+
157+
test('thrown fetch error (timeout/ECONNRESET) on final direct attempt → proxy fallback runs (P1 fix)', async () => {
158+
// Pre-fix bug: the catch block did `throw err` after the final direct retry,
159+
// which silently bypassed proxy fallback for thrown-error cases (timeout,
160+
// ECONNRESET, DNS). Lock the new control flow: thrown error → break →
161+
// proxy fallback runs.
162+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
163+
164+
let directCalls = 0;
165+
globalThis.fetch = async () => {
166+
directCalls += 1;
167+
throw Object.assign(new Error('Connect Timeout Error'), { code: 'UND_ERR_CONNECT_TIMEOUT' });
168+
};
169+
170+
let proxyCalls = 0;
171+
const result = await fetchOpenMeteoArchiveBatch(ZONES, {
172+
...ARCHIVE_OPTS,
173+
_proxyResolver: () => 'user:pass@proxy.test:8000',
174+
_proxyFetcher: async () => {
175+
proxyCalls += 1;
176+
return { buffer: Buffer.from(JSON.stringify(VALID_PAYLOAD), 'utf8'), contentType: 'application/json' };
177+
},
178+
});
179+
180+
assert.equal(directCalls, 2, 'direct attempts should exhaust retries before proxy');
181+
assert.equal(proxyCalls, 1, 'proxy fallback MUST run on thrown-error path (regression guard)');
182+
assert.equal(result.length, 2);
183+
});
184+
185+
test('429 + proxy configured + proxy ALSO fails: throws exhausted with last direct error in cause', async () => {
186+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
187+
188+
globalThis.fetch = async () => ({
189+
ok: false, status: 429,
190+
headers: { get: () => null },
191+
json: async () => ({}),
192+
});
193+
194+
let proxyCalls = 0;
195+
await assert.rejects(
196+
() => fetchOpenMeteoArchiveBatch(ZONES, {
197+
...ARCHIVE_OPTS,
198+
_proxyResolver: () => 'user:pass@proxy.test:8000',
199+
_proxyFetcher: async () => {
200+
proxyCalls += 1;
201+
throw new Error('proxy 502');
202+
},
203+
}),
204+
(err) => {
205+
assert.match(err.message, /Open-Meteo retries exhausted/);
206+
assert.match(err.message, /HTTP 429/);
207+
return true;
208+
},
209+
);
210+
assert.equal(proxyCalls, 1);
211+
});
212+
213+
test('proxy fallback returns wrong batch size: caught + warns, throws exhausted', async () => {
214+
const { fetchOpenMeteoArchiveBatch } = await import(`../scripts/_open-meteo-archive.mjs?t=${Date.now()}`);
215+
216+
globalThis.fetch = async () => ({
217+
ok: false, status: 429,
218+
headers: { get: () => null },
219+
json: async () => ({}),
220+
});
221+
222+
await assert.rejects(
223+
() => fetchOpenMeteoArchiveBatch(ZONES, {
224+
...ARCHIVE_OPTS,
225+
_proxyResolver: () => 'user:pass@proxy.test:8000',
226+
_proxyFetcher: async () => ({
227+
buffer: Buffer.from(JSON.stringify([VALID_PAYLOAD[0]]), 'utf8'), // 1 instead of 2
228+
contentType: 'application/json',
229+
}),
230+
}),
231+
/Open-Meteo retries exhausted/,
232+
);
233+
});

0 commit comments

Comments
 (0)