Files
worldmonitor/scripts/seed-comtrade-bilateral-hs4.mjs
Elie Habib 16d868bd6d fix(comtrade): retry on transient 5xx to stop silent reporter drops (#3084)
* fix(comtrade): retry on transient 5xx to stop silent reporter drops

Railway log 2026-04-14 bilateral-hs4 run: India (699) hit HTTP 503 on both
batches and was dropped entirely from the snapshot. Iran (364) hit 500
mid-batch. All three Comtrade seeders (bilateral-hs4, trade-flows,
recovery-import-hhi) retried only on 429; any 5xx = silent coverage gap.

Adds bounded 5xx retry (3 attempts, 5s then 15s backoff) in each seeder.
On giveup caller returns empty so resume cache picks the reporter up next
cycle. Exports isTransientComtrade + fetchBilateral for unit tests; 6 new
tests pin the contract.

* fix(comtrade): collapse 429+5xx into single classification loop (PR review)

Reviewer caught that the 429 branch bypassed the 5xx retry path: a 429 ->
503 sequence would return [] immediately after the 429-retry without
consuming any transient-5xx retries, leaving the silent-drop bug intact
for that specific sequence.

Both seeders now use a single while-loop that reclassifies each response:
- 429 (once, with full backoff)
- 5xx (up to 2 retries with 5s/15s or 5s/10s backoff)
- anything else -> break and return

Two new tests lock in the mixed case: 429 then 503 still consumes
transient retries; consecutive 429s cap at one wait. 8/8 pass.

* test(comtrade): inject sleep to drop retry-test runtime from 185s to 277ms

PR review flagged that the new mixed 429+5xx tests slept for the full
production backoffs (60s + 5s + 15s = 80s per test), making the unit
suite unnecessarily slow and CI-timeout-prone.

Add a module-local _retrySleep binding with __setSleepForTests(fn)
export. Production keeps the real sleep; tests swap in a no-op that
records requested delays. The sleepCalls array now pins the production
cadence so a future refactor that changes [60_000, 5_000, 15_000] has
to update the test too.

8/8 pass in 277ms (down from 185s).

* test(comtrade): update 60s-on-429 static-analysis regex for _retrySleep alias

The existing substring check 'sleep(60_000)' broke after the previous
commit renamed production calls to _retrySleep(60_000) for test injection.
Widen the regex to accept either the bare or injectable form; both
preserve the 60s production cadence.

* test(comtrade): extend retry coverage to trade-flows + recovery-import-hhi

Three P2 review findings addressed:

1. partnerCode 000 in the succeeds-on-third test was changed to real code
   156. groupByProduct() filters 0/000 downstream, so the test was passing
   while the user-visible seeder would still drop the row.
2. trade-flows and recovery-import-hhi had no unit coverage for their new
   retry state machines. Adds 7 tests covering succeed-first, retry-then-
   succeed, giveup-after-3, and mixed 429+5xx classification.
3. Both seeders now expose __setSleepForTests + export their fetch helper.
   seed-trade-flows also gets an isMain guard so tests import without
   triggering a real seed run. sleepCalls asserts pin the production
   cadence.

15 retry tests pass in 183ms. Full suite 5198/5198.

* fix(trade-flows): per-reporter coverage gate blocks full-reporter flatline

PR #3084 review P1: the existing MIN_COVERAGE_RATIO=0.70 gate was
global-only. 6 reporters × 5 commodities = 30 pairs; losing one entire
reporter (e.g. the India/Taiwan silent-drop this PR is trying to stop)
is only 5/30 missing = 83% global coverage, which passed.

Adds a per-reporter coverage floor: each reporter must have ≥40% of
its commodities populated (2 of 5). Global gate kept as the broad-
outage catch; per-reporter gate catches the single-reporter flatline.

Extracts checkCoverage() as a pure function for unit testing — mocking
30+ fetches in fetchAllFlows is fragile, and the failure mode lives in
the gate, not the fetcher.

6 new tests cover: 30/30 ok; India flatline → reject at 83% global;
Taiwan flatline; broad outage → reject via global gate; healthy
80% global with 4/5 per-reporter → ok; per-reporter breakdown shape.

5204/5204 tests pass.
2026-04-14 12:29:17 +04:00

363 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// @ts-check
import { createRequire } from 'node:module';
import {
acquireLockSafely,
CHROME_UA,
extendExistingTtl,
getRedisCredentials,
loadEnvFile,
logSeedResult,
releaseLock,
sleep,
} from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const META_KEY = 'seed-meta:comtrade:bilateral-hs4';
const KEY_PREFIX = 'comtrade:bilateral-hs4:';
const TTL_SECONDS = 259200; // 72h
const LOCK_DOMAIN = 'comtrade:bilateral-hs4';
const LOCK_TTL_MS = 30 * 60 * 1000; // 30 min
const COMTRADE_KEYS = (process.env.COMTRADE_API_KEYS || '').split(',').map(k => k.trim()).filter(Boolean);
let keyIndex = 0;
function getNextKey() {
if (COMTRADE_KEYS.length === 0) return '';
const key = COMTRADE_KEYS[keyIndex % COMTRADE_KEYS.length];
keyIndex++;
return key;
}
const usePublicApi = COMTRADE_KEYS.length === 0;
const COMTRADE_FETCH_URL = usePublicApi
? 'https://comtradeapi.un.org/public/v1/preview/C/A/HS'
: 'https://comtradeapi.un.org/data/v1/get/C/A/HS';
const INTER_REQUEST_DELAY_MS = usePublicApi ? 3500 : 1500;
const HS4_CODES = [
'2709', '2711', '8542', '8517', '8703', '3004', '7108', '2710',
'8471', '8411', '7601', '7202', '3901', '2902', '1001', '1201',
'6204', '0203', '8704', '8708',
];
const HS4_LABELS = {
'2709': 'Crude Petroleum',
'2711': 'LNG & Petroleum Gas',
'8542': 'Semiconductors',
'8517': 'Smartphones & Telecom',
'8703': 'Passenger Vehicles',
'3004': 'Pharmaceuticals',
'7108': 'Gold',
'2710': 'Refined Petroleum',
'8471': 'Computers',
'8411': 'Turbojets & Turbines',
'7601': 'Aluminium',
'7202': 'Ferroalloys (Steel)',
'3901': 'Plastics (Polyethylene)',
'2902': 'Chemicals (Hydrocarbons)',
'1001': 'Wheat',
'1201': 'Soybeans',
'6204': 'Garments',
'0203': 'Pork',
'8704': 'Trucks',
'8708': 'Vehicle Parts',
};
const BATCH_1 = HS4_CODES.slice(0, 10);
const BATCH_2 = HS4_CODES.slice(10);
const require = createRequire(import.meta.url);
/** @type {Record<string, {nearestRouteIds: string[], coastSide: string}>} */
const COUNTRY_PORT_CLUSTERS = require('./shared/country-port-clusters.json');
/** @type {Record<string, string>} */
const UN_TO_ISO2 = require('./shared/un-to-iso2.json');
const ISO2_TO_UN = Object.fromEntries(
Object.entries(UN_TO_ISO2).map(([un, iso2]) => [iso2, un]),
);
// UN Comtrade uses non-standard reporter codes for some countries.
// These override the standard UN M49 codes from un-to-iso2.json.
const COMTRADE_REPORTER_OVERRIDES = {
FR: '251', // UN M49 standard is 250, but Comtrade registers France as reporter 251
IT: '381', // UN M49 standard is 380, but Comtrade registers Italy as reporter 381
US: '842', // UN M49 standard is 840, but Comtrade registers the US as reporter 842
IN: '699', // UN M49 standard is 356, Comtrade registers India as reporter 699
TW: '490', // M49 has no entry; Comtrade reports Taiwan as 490 "Other Asia, nes"
};
/**
* @param {Array<string[]>} commands
*/
async function redisPipeline(commands) {
const { url, token } = getRedisCredentials();
const resp = await fetch(`${url}/pipeline`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(commands),
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`Redis pipeline failed: HTTP ${resp.status}${text.slice(0, 200)}`);
}
return resp.json();
}
/**
* @param {string} reporterCode
* @param {string[]} hs4Batch
* @returns {Promise<Array<{cmdCode: string, partnerCode: string, primaryValue: number, year: number}>>}
*/
// Comtrade's API regularly returns transient 5xx (500/502/503/504) on otherwise
// valid reporter fetches — observed 2026-04-14 with India (699) 503×2 and
// Iran (364) 500. Without a 5xx retry those reporters silently drop from
// the snapshot and the panel shows missing countries for a full cycle.
export function isTransientComtrade(status) {
return status === 500 || status === 502 || status === 503 || status === 504;
}
// Retry sleep is indirected through a module-local binding so unit tests can
// swap in a no-op without changing production cadence. Production defaults
// to the real sleep import; tests call __setSleepForTests(() => Promise.resolve()).
let _retrySleep = sleep;
export function __setSleepForTests(fn) { _retrySleep = typeof fn === 'function' ? fn : sleep; }
async function fetchBilateralOnce(url, timeoutMs = 45_000) {
return fetch(url, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
}
function buildFetchUrl(reporterCode, hs4Batch, key) {
const url = new URL(COMTRADE_FETCH_URL);
url.searchParams.set('reporterCode', reporterCode);
url.searchParams.set('cmdCode', hs4Batch.join(','));
url.searchParams.set('flowCode', 'M');
if (key) url.searchParams.set('subscription-key', key);
return url.toString();
}
/**
* Single classification loop so a post-429 5xx still consumes the bounded
* 5xx retries (and vice versa). Caps: one 429 wait (60s), then up to two
* transient-5xx retries (5s, 15s). Any non-transient non-OK status exits.
*
* @param {string} reporterCode
* @param {string[]} hs4Batch
* @returns {Promise<Array<{cmdCode: string, partnerCode: string, primaryValue: number, year: number}>>}
*/
export async function fetchBilateral(reporterCode, hs4Batch) {
let rateLimitedOnce = false;
let transientRetries = 0;
const MAX_TRANSIENT_RETRIES = 2;
let resp;
while (true) {
resp = await fetchBilateralOnce(buildFetchUrl(reporterCode, hs4Batch, getNextKey()));
if (resp.status === 429 && !rateLimitedOnce) {
console.warn(` 429 rate-limited for reporter ${reporterCode}, waiting 60s...`);
await _retrySleep(60_000);
rateLimitedOnce = true;
continue;
}
if (isTransientComtrade(resp.status) && transientRetries < MAX_TRANSIENT_RETRIES) {
const delay = transientRetries === 0 ? 5_000 : 15_000;
console.warn(` transient HTTP ${resp.status} for reporter ${reporterCode}, retrying in ${delay / 1000}s...`);
await _retrySleep(delay);
transientRetries++;
continue;
}
break;
}
if (!resp.ok) {
const tag = (rateLimitedOnce || transientRetries > 0) ? ' (after retries)' : '';
console.warn(` HTTP ${resp.status} for reporter ${reporterCode}${tag}`);
return [];
}
const data = await resp.json();
const parsed = parseRecords(data);
if (parsed.length === 0 && data?.count > 0) {
console.warn(` Reporter ${reporterCode}: API returned count=${data.count} but parseRecords produced 0 — response shape may have changed`);
}
return parsed;
}
/**
* @param {unknown} data
* @returns {Array<{cmdCode: string, partnerCode: string, primaryValue: number, year: number}>}
*/
function parseRecords(data) {
const records = /** @type {any[]} */ (/** @type {any} */ (data)?.data ?? []);
if (!Array.isArray(records)) return [];
return records
.filter(r => r && Number(r.primaryValue ?? 0) > 0)
.map(r => ({
cmdCode: String(r.cmdCode ?? ''),
partnerCode: String(r.partnerCode ?? r.partner2Code ?? '000'),
primaryValue: Number(r.primaryValue ?? 0),
year: Number(r.period ?? r.refYear ?? 0),
}));
}
/**
* @param {Array<{cmdCode: string, partnerCode: string, primaryValue: number, year: number}>} records
* @returns {Array<{hs4: string, description: string, totalValue: number, topExporters: Array<{partnerCode: number, partnerIso2: string, value: number, share: number}>, year: number}>}
*/
function groupByProduct(records) {
/** @type {Map<string, Map<string, {value: number, year: number}>>} */
const byCode = new Map();
for (const r of records) {
if (!byCode.has(r.cmdCode)) byCode.set(r.cmdCode, new Map());
const partners = byCode.get(r.cmdCode);
const existing = partners.get(r.partnerCode);
if (!existing || r.primaryValue > existing.value) {
partners.set(r.partnerCode, { value: r.primaryValue, year: r.year });
}
}
const products = [];
for (const [hs4, partners] of byCode) {
const sorted = [...partners.entries()]
.sort((a, b) => b[1].value - a[1].value)
.filter(([pc]) => pc !== '0' && pc !== '000');
const totalValue = sorted.reduce((s, [, v]) => s + v.value, 0);
if (totalValue <= 0) continue;
const top5 = sorted.slice(0, 5);
const latestYear = Math.max(...sorted.map(([, v]) => v.year).filter(y => y > 0));
products.push({
hs4,
description: HS4_LABELS[hs4] ?? hs4,
totalValue,
topExporters: top5.map(([pc, v]) => ({
partnerCode: Number(pc),
partnerIso2: UN_TO_ISO2[pc.padStart(3, '0')] ?? '',
value: v.value,
share: Math.round((v.value / totalValue) * 1000) / 1000,
})),
year: latestYear || 2023,
});
}
return products.sort((a, b) => b.totalValue - a.totalValue);
}
export async function main() {
const startedAt = Date.now();
const runId = `${LOCK_DOMAIN}:${startedAt}`;
const lock = await acquireLockSafely(LOCK_DOMAIN, runId, LOCK_TTL_MS, { label: LOCK_DOMAIN });
const countries = Object.entries(COUNTRY_PORT_CLUSTERS)
.filter(([k]) => k !== '_comment' && k.length === 2);
const allKeys = countries.map(([iso2]) => `${KEY_PREFIX}${iso2}:v1`);
if (lock.skipped) {
await extendExistingTtl([...allKeys, META_KEY], TTL_SECONDS)
.catch(e => console.warn('[bilateral-hs4] TTL extension (skipped) failed:', e.message));
return;
}
if (!lock.locked) {
console.log('[bilateral-hs4] Lock held, skipping');
return;
}
const writeMeta = async (count, status = 'ok') => {
const meta = JSON.stringify({ fetchedAt: Date.now(), recordCount: count, status });
await redisPipeline([['SET', META_KEY, meta, 'EX', String(TTL_SECONDS * 3)]])
.catch(e => console.warn('[bilateral-hs4] Failed to write seed-meta:', e.message));
};
try {
const apiMode = usePublicApi ? 'public preview (no COMTRADE_API_KEYS)' : `authenticated (${COMTRADE_KEYS.length} key(s), ${INTER_REQUEST_DELAY_MS}ms delay)`;
console.log(`[bilateral-hs4] Fetching bilateral HS4 data for ${countries.length} countries × ${HS4_CODES.length} products [${apiMode}]...`);
const commands = [];
let writtenCount = 0;
let failedCount = 0;
let requestCount = 0;
for (let i = 0; i < countries.length; i++) {
const [iso2] = countries[i];
const unCode = COMTRADE_REPORTER_OVERRIDES[iso2] ?? ISO2_TO_UN[iso2];
if (!unCode) {
console.warn(` ${iso2}: no UN code, skipping`);
continue;
}
if (requestCount > 0) await sleep(INTER_REQUEST_DELAY_MS);
try {
console.log(` [${i + 1}/${countries.length}] ${iso2} batch 1/2...`);
const batch1 = await fetchBilateral(unCode, BATCH_1);
requestCount++;
await sleep(INTER_REQUEST_DELAY_MS);
console.log(` [${i + 1}/${countries.length}] ${iso2} batch 2/2...`);
const batch2 = await fetchBilateral(unCode, BATCH_2);
requestCount++;
const products = groupByProduct([...batch1, ...batch2]);
if (products.length === 0) {
console.warn(` ${iso2}: no products after grouping, skipping write`);
} else {
const payload = JSON.stringify({
iso2,
products,
fetchedAt: new Date().toISOString(),
});
commands.push(['SET', `${KEY_PREFIX}${iso2}:v1`, payload, 'EX', String(TTL_SECONDS)]);
writtenCount++;
console.log(` ${iso2}: ${products.length} products, ${batch1.length + batch2.length} records`);
}
} catch (err) {
console.warn(` [bilateral-hs4] ${iso2}: fetch failed, preserving existing data: ${err.message}`);
failedCount++;
}
if (commands.length >= 50) {
await redisPipeline(commands.splice(0));
}
}
if (commands.length > 0) {
await redisPipeline(commands);
}
await writeMeta(writtenCount);
logSeedResult('comtrade:bilateral-hs4', writtenCount, Date.now() - startedAt, {
countries: countries.length,
failed: failedCount,
hs4Codes: HS4_CODES.length,
requests: requestCount,
ttlH: TTL_SECONDS / 3600,
});
console.log(`[bilateral-hs4] Seeded ${writtenCount} country keys (${failedCount} failed, existing data preserved)`);
} catch (err) {
console.error('[bilateral-hs4] Seed failed:', err.message || err);
await extendExistingTtl([...allKeys, META_KEY], TTL_SECONDS)
.catch(e => console.warn('[bilateral-hs4] TTL extension failed:', e.message));
await writeMeta(0, 'error');
throw err;
} finally {
await releaseLock(LOCK_DOMAIN, runId);
}
}
const isMain = process.argv[1]?.endsWith('seed-comtrade-bilateral-hs4.mjs');
if (isMain) {
main().catch(err => {
console.error(err);
process.exit(1);
});
}