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
This commit is contained in:
Elie Habib
2026-04-07 22:56:08 +04:00
committed by GitHub
parent 47af642d24
commit 9eb37a4d62
4 changed files with 82 additions and 4 deletions

View File

@@ -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),
});
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}`);
const body = await resp.json();
body = await resp.json();
}
if (body.error) throw new Error(`ArcGIS chokepoints-ref error: ${body.error.message}`);
const features = body.features ?? [];

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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) {