semillero-INVIMA/backend/APIinvima.js

131 lines
4.1 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const configPath = path.join(__dirname, 'APIinvima.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
const INVIMA_DATASETS = Object.fromEntries(config.datasets.map((dataset) => [dataset.key, dataset]));
const INVIMA_DATASET_IDS = Object.fromEntries(config.datasets.map((dataset) => [dataset.id, dataset]));
const ALLOWED_SODA_PARAMS = ['$limit', '$offset', '$select', '$where', '$order'];
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const clampInt = (value, fallback, min, max) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (Number.isNaN(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
};
const computeBackoffMs = (attempt, baseMs = 800, maxMs = 8000) => {
const exp = baseMs * 2 ** attempt;
const jitter = Math.floor(Math.random() * 250);
return Math.min(maxMs, exp + jitter);
};
const isNetworkRetryableError = (error) => {
const code = String(error?.cause?.code || error?.code || '').toUpperCase();
if (code.includes('TIMEOUT') || code.includes('ECONNRESET') || code.includes('EAI_AGAIN')) return true;
const message = String(error?.message || '').toLowerCase();
return (
message.includes('fetch failed') ||
message.includes('timeout') ||
message.includes('socket hang up') ||
message.includes('temporar')
);
};
const cleanParamValue = (value) => {
if (value === undefined || value === null) return null;
const text = String(value).trim();
return text ? text : null;
};
const buildInvimaUrl = (datasetId, params = {}) => {
const url = new URL(`${config.baseUrl}/${datasetId}.json`);
for (const key of ALLOWED_SODA_PARAMS) {
const value = cleanParamValue(params[key]);
if (value !== null) {
url.searchParams.set(key, value);
}
}
return url.toString();
};
const resolveDataset = (datasetKeyOrId) => {
return INVIMA_DATASETS[datasetKeyOrId] || INVIMA_DATASET_IDS[datasetKeyOrId] || null;
};
const fetchInvimaDataset = async (
datasetKeyOrId,
options = {},
appToken = process.env.SOCRATA_APP_TOKEN,
requestOptions = {}
) => {
const dataset = resolveDataset(datasetKeyOrId);
if (!dataset) {
throw new Error(`Dataset no reconocido: ${datasetKeyOrId}`);
}
const url = buildInvimaUrl(dataset.id, options);
const headers = { Accept: 'application/json' };
const maxRetries = clampInt(requestOptions.retries, 4, 0, 8);
const timeoutMs = clampInt(requestOptions.timeoutMs, 35000, 4000, 120000);
if (appToken) {
headers['X-App-Token'] = appToken;
}
let lastError = null;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const attemptLabel = `${attempt + 1}/${maxRetries + 1}`;
try {
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined;
const response = await fetch(url, { headers, signal });
if (!response.ok) {
const body = await response.text();
const error = new Error(
`Error consultando ${dataset.key} (${dataset.id}): ${response.status} ${response.statusText}. ${body}`
);
lastError = error;
if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) {
await sleep(computeBackoffMs(attempt));
continue;
}
throw error;
}
return response.json();
} catch (error) {
lastError = error;
if (attempt < maxRetries && isNetworkRetryableError(error)) {
await sleep(computeBackoffMs(attempt));
continue;
}
if (error instanceof Error && error.message.includes('Error consultando')) {
throw error;
}
throw new Error(
`Error consultando ${dataset.key} (${dataset.id}) [intento ${attemptLabel}]: ${String(error?.message || error)}`
);
}
}
throw lastError || new Error(`No fue posible consultar dataset ${dataset.key} (${dataset.id}).`);
};
module.exports = {
apiConfig: config,
INVIMA_DATASETS,
INVIMA_DATASET_IDS,
ALLOWED_SODA_PARAMS,
buildInvimaUrl,
fetchInvimaDataset
};