fix(csf): retry con backoff, delays entre tenants, timeouts aumentados

This commit is contained in:
Horux Dev
2026-06-01 23:43:43 +00:00
parent 44144ebf9d
commit bd7e499ab7
3 changed files with 73 additions and 62 deletions

View File

@@ -351,6 +351,8 @@ async function runCsfJob(): Promise<void> {
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message); console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
failed++; failed++;
} }
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
await new Promise(r => setTimeout(r, 30_000));
} }
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`); console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
} }

View File

@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta * sincroniza automáticamente domicilio + regímenes activos con lo que reporta
* el SAT. El auto-fill NO es destructivo para datos custom del usuario: * el SAT. El auto-fill NO es destructivo para datos custom del usuario:
* solo sobreescribe campos si la CSF tiene un valor no-vacío. * solo sobreescribe campos si la CSF tiene un valor no-vacío.
*
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
*/ */
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> { export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
const fiel = await getDecryptedFiel(tenantId); const fiel = await getDecryptedFiel(tenantId);
@@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
}); });
if (!tenant) throw new Error('Tenant no encontrado'); if (!tenant) throw new Error('Tenant no encontrado');
const tempId = randomUUID(); const MAX_RETRIES = 3;
const tempDir = join(tmpdir(), `horux-csf-${tempId}`); const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
const cerPath = join(tempDir, 'cert.cer');
const keyPath = join(tempDir, 'key.key');
try { for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); const tempId = randomUUID();
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
const cerPath = join(tempDir, 'cert.cer');
const keyPath = join(tempDir, 'key.key');
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
// caso donde el click sintético no dispara el handler del SAT. Si algún
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});
try { try {
const timeoutPromise = new Promise<never>((_, reject) => writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
);
const resultPromise = (async () => { const headless = process.env.SAT_HEADLESS !== 'false';
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc); const browser = await chromium.launch({
const pdfBuffer = await extractCsfPdf(session); headless,
const csf = await parseCsfPdf(pdfBuffer); args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); });
const { rows } = await pool.query( try {
`INSERT INTO constancias_situacion_fiscal const timeoutPromise = new Promise<never>((_, reject) =>
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
datos, fecha_consulta, created_at`,
[
csf.rfc,
csf.idCIF,
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
csf.estatusPadron,
csf.lugarFechaEmision,
JSON.stringify(csf),
pdfBuffer,
],
); );
// Auto-fill domicilio del tenant + regímenes activos desde el CSF. const resultPromise = (async () => {
// Se hace después del INSERT para que si algo falla en la sincronización const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
// la CSF ya quedó guardada y el usuario puede verla. const pdfBuffer = await extractCsfPdf(session);
await sincronizarDatosFiscales(tenantId, csf).catch(err => { const csf = await parseCsfPdf(pdfBuffer);
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
});
return rowToConstancia(rows[0]); const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
})(); const { rows } = await pool.query(
`INSERT INTO constancias_situacion_fiscal
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
datos, fecha_consulta, created_at`,
[
csf.rfc,
csf.idCIF,
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
csf.estatusPadron,
csf.lugarFechaEmision,
JSON.stringify(csf),
pdfBuffer,
],
);
return await Promise.race([resultPromise, timeoutPromise]); await sincronizarDatosFiscales(tenantId, csf).catch(err => {
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
});
return rowToConstancia(rows[0]);
})();
return await Promise.race([resultPromise, timeoutPromise]);
} finally {
await browser.close();
}
} catch (err: any) {
const willRetry = attempt < MAX_RETRIES - 1;
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
if (!willRetry) throw err;
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
} finally { } finally {
await browser.close(); try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ }
} }
} finally {
try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ }
} }
throw new Error('No debería llegar aquí');
} }
/** /**

View File

@@ -30,20 +30,20 @@ export async function loginSatCsf(
const publicPage = await context.newPage(); const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000); publicPage.setDefaultTimeout(60_000);
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
await publicPage.waitForTimeout(2000); await publicPage.waitForTimeout(3000);
// Click acordeón "Obtén tu constancia" / "Obtener constancia" // Click acordeón "Obtén tu constancia" / "Obtener constancia"
const obtenerLocator = publicPage.locator( const obtenerLocator = publicPage.locator(
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i', 'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
).first(); ).first();
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
await obtenerLocator.scrollIntoViewIfNeeded(); await obtenerLocator.scrollIntoViewIfNeeded();
await obtenerLocator.click(); await obtenerLocator.click();
await publicPage.waitForTimeout(1500); await publicPage.waitForTimeout(1500);
// Click "SERVICIO" → popup // Click "SERVICIO" → popup
const popupPromise = context.waitForEvent('page', { timeout: 60_000 }); const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click(); await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
const loginPage = await popupPromise; const loginPage = await popupPromise;
await loginPage.waitForLoadState('domcontentloaded'); await loginPage.waitForLoadState('domcontentloaded');
@@ -56,7 +56,7 @@ export async function loginSatCsf(
const efirmaBtn = loginPage const efirmaBtn = loginPage
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]') .locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
.first(); .first();
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
await efirmaBtn.scrollIntoViewIfNeeded(); await efirmaBtn.scrollIntoViewIfNeeded();
await efirmaBtn.click(); await efirmaBtn.click();
@@ -82,7 +82,7 @@ export async function loginSatCsf(
return rfc !== null && rfc.value.length >= 12; return rfc !== null && rfc.value.length >= 12;
}, },
null, null,
{ timeout: 60_000 }, { timeout: 120_000 },
); );
rfcPopulated = true; rfcPopulated = true;
} catch { } catch {
@@ -121,7 +121,7 @@ export async function loginSatCsf(
// Esperar a que salga del dominio de login y aterrice en el portal SAT // Esperar a que salga del dominio de login y aterrice en el portal SAT
await loginPage.waitForURL( await loginPage.waitForURL(
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
{ timeout: 60_000 }, { timeout: 120_000 },
); );
await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000); await loginPage.waitForTimeout(2000);