From 9eb37a4d62d1b351cd92d963b82c74c46dbac5aa Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Tue, 7 Apr 2026 22:56:08 +0400 Subject: [PATCH] fix(portwatch): proxy fallback on ArcGIS 429 + verbose SKIPPED log (#2801) * fix(portwatch): proxy fallback on ArcGIS 429 + verbose SKIPPED log * fix(portwatch): use resolveProxyForConnect for httpsProxyFetchRaw CONNECT tunnel * test(portwatch): assert 429 proxy fallback and SKIPPED log coverage --- scripts/seed-portwatch-chokepoints-ref.mjs | 14 +++++-- scripts/seed-portwatch-port-activity.mjs | 12 +++++- tests/portwatch-chokepoints-ref-seed.test.mjs | 23 ++++++++++++ tests/portwatch-port-activity-seed.test.mjs | 37 +++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/scripts/seed-portwatch-chokepoints-ref.mjs b/scripts/seed-portwatch-chokepoints-ref.mjs index 28c96ffba..e5dbe8416 100644 --- a/scripts/seed-portwatch-chokepoints-ref.mjs +++ b/scripts/seed-portwatch-chokepoints-ref.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { loadEnvFile, runSeed, CHROME_UA } from './_seed-utils.mjs'; +import { loadEnvFile, runSeed, CHROME_UA, resolveProxyForConnect, httpsProxyFetchRaw } from './_seed-utils.mjs'; loadEnvFile(import.meta.url); @@ -29,8 +29,16 @@ export async function fetchAll() { headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT), }); - if (!resp.ok) throw new Error(`ArcGIS HTTP ${resp.status}`); - const body = await resp.json(); + let body; + if (resp.status === 429) { + const proxyAuth = resolveProxyForConnect(); + if (!proxyAuth) throw new Error('ArcGIS HTTP 429 (rate limited) and no PROXY_URL configured'); + const { buffer } = await httpsProxyFetchRaw(`${ARCGIS_BASE}?${params}`, proxyAuth, { accept: 'application/json', timeoutMs: FETCH_TIMEOUT }); + body = JSON.parse(buffer.toString('utf8')); + } else { + if (!resp.ok) throw new Error(`ArcGIS HTTP ${resp.status}`); + body = await resp.json(); + } if (body.error) throw new Error(`ArcGIS chokepoints-ref error: ${body.error.message}`); const features = body.features ?? []; diff --git a/scripts/seed-portwatch-port-activity.mjs b/scripts/seed-portwatch-port-activity.mjs index c9552e066..7500038a8 100644 --- a/scripts/seed-portwatch-port-activity.mjs +++ b/scripts/seed-portwatch-port-activity.mjs @@ -9,6 +9,8 @@ import { extendExistingTtl, logSeedResult, readSeedSnapshot, + resolveProxyForConnect, + httpsProxyFetchRaw, } from './_seed-utils.mjs'; import { createCountryResolvers } from './_country-resolver.mjs'; @@ -44,6 +46,14 @@ async function fetchWithTimeout(url) { headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT), }); + if (resp.status === 429) { + const proxyAuth = resolveProxyForConnect(); + if (!proxyAuth) throw new Error(`ArcGIS HTTP 429 (rate limited) for ${url.slice(0, 80)}`); + const { buffer } = await httpsProxyFetchRaw(url, proxyAuth, { accept: 'application/json', timeoutMs: FETCH_TIMEOUT }); + const proxied = JSON.parse(buffer.toString('utf8')); + if (proxied.error) throw new Error(`ArcGIS error (via proxy): ${proxied.error.message}`); + return proxied; + } if (!resp.ok) throw new Error(`ArcGIS HTTP ${resp.status} for ${url.slice(0, 80)}`); const body = await resp.json(); if (body.error) throw new Error(`ArcGIS error: ${body.error.message}`); @@ -239,7 +249,7 @@ async function main() { const lock = await acquireLockSafely(LOCK_DOMAIN, runId, LOCK_TTL_MS, { label: LOCK_DOMAIN }); if (lock.skipped) return; if (!lock.locked) { - console.log(' SKIPPED: another seed run in progress'); + console.log(` SKIPPED: another seed run in progress (lock: seed-lock:${LOCK_DOMAIN}, held up to ${LOCK_TTL_MS / 60000}min — will retry at next cron trigger)`); return; } diff --git a/tests/portwatch-chokepoints-ref-seed.test.mjs b/tests/portwatch-chokepoints-ref-seed.test.mjs index ad1b082b5..baf4eddba 100644 --- a/tests/portwatch-chokepoints-ref-seed.test.mjs +++ b/tests/portwatch-chokepoints-ref-seed.test.mjs @@ -58,6 +58,29 @@ describe('seed-portwatch-chokepoints-ref.mjs exports', () => { }); }); +describe('ArcGIS 429 proxy fallback', () => { + it('imports resolveProxyForConnect and httpsProxyFetchRaw', () => { + assert.match(src, /resolveProxyForConnect/); + assert.match(src, /httpsProxyFetchRaw/); + }); + + it('fetchAll checks resp.status === 429', () => { + assert.match(src, /resp\.status\s*===\s*429/); + }); + + it('calls resolveProxyForConnect() on 429', () => { + assert.match(src, /resolveProxyForConnect\(\)/); + }); + + it('calls httpsProxyFetchRaw with proxy auth on 429', () => { + assert.match(src, /httpsProxyFetchRaw\(.*proxyAuth/s); + }); + + it('throws if 429 and no proxy configured', () => { + assert.match(src, /429.*rate limited/); + }); +}); + // ── unit tests for chokepoint reference data building ───────────────────────── function buildEntry(a) { diff --git a/tests/portwatch-port-activity-seed.test.mjs b/tests/portwatch-port-activity-seed.test.mjs index dc4825402..e85636b0a 100644 --- a/tests/portwatch-port-activity-seed.test.mjs +++ b/tests/portwatch-port-activity-seed.test.mjs @@ -67,6 +67,43 @@ describe('seed-portwatch-port-activity.mjs exports', () => { }); }); +describe('ArcGIS 429 proxy fallback', () => { + it('imports resolveProxyForConnect and httpsProxyFetchRaw', () => { + assert.match(src, /resolveProxyForConnect/); + assert.match(src, /httpsProxyFetchRaw/); + }); + + it('fetchWithTimeout checks resp.status === 429', () => { + assert.match(src, /resp\.status\s*===\s*429/); + }); + + it('calls resolveProxyForConnect() on 429', () => { + assert.match(src, /resolveProxyForConnect\(\)/); + }); + + it('calls httpsProxyFetchRaw with proxy auth on 429', () => { + assert.match(src, /httpsProxyFetchRaw\(url,\s*proxyAuth/); + }); + + it('throws if 429 and no proxy configured', () => { + assert.match(src, /429.*rate limited/); + }); +}); + +describe('SKIPPED log message', () => { + it('includes lock domain in SKIPPED message', () => { + assert.match(src, /SKIPPED.*seed-lock.*LOCK_DOMAIN/s); + }); + + it('includes TTL duration in SKIPPED message', () => { + assert.match(src, /LOCK_TTL_MS\s*\/\s*60000/); + }); + + it('mentions next cron trigger in SKIPPED message', () => { + assert.match(src, /next cron trigger/); + }); +}); + // ── unit tests ──────────────────────────────────────────────────────────────── function computeAnomalySignal(rows, cutoff30, cutoff7) {