From 7e9f21a25ff9df831b456503dba39101ddf69fb8 Mon Sep 17 00:00:00 2001 From: Pachedev Date: Mon, 31 Aug 2026 15:37:30 -0600 Subject: [PATCH] fix(nextor): mantiene una sola IP residencial en toda la consulta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getResidentialProxyUrl() elige una entrada al azar de RESIDENTIAL_PROXIES en cada llamada, y getProxyAgent() se invocaba por separado para /iniciar y para /pre-check. El sessionId que emite la primera petición puede así presentarse desde una IP distinta en la segunda; contra un endpoint cuyo problema conocido es el rate limit por IP, es justo lo que no debe pasar. Con dos entradas configuradas, diez llamadas seguidas alternan entre ambas, de modo que alrededor de la mitad de las consultas parten la sesión. Ahora se construye un ProxyAgent por consulta, se comparte entre las dos peticiones y se cierra en un finally. Eso ahorra un handshake TCP+TLS contra el proxy —facturado por GB— y permite fijar allowH2:false y keepAliveTimeout de 10s, los mismos valores del dispatcher global de instrumentation.ts: un dispatcher explícito se lo salta, así que los providers proxeados eran los únicos sin la protección contra sockets muertos que introdujo 153aedf. Contra el endpoint real sin proxy, el código anterior y el nuevo devuelven lo mismo. El diff se revisa mejor con `git diff -w`: son 31 líneas de cambio real, el resto es reindentado por el try/finally. freedompop.ts y talentonet/mvno.ts comparten el patrón de crear un agente por petición pero no el bug de sesión partida; se dejan fuera para no ampliar el alcance. --- src/lib/providers/nextor-movil.ts | 179 +++++++++++++++++------------- 1 file changed, 103 insertions(+), 76 deletions(-) diff --git a/src/lib/providers/nextor-movil.ts b/src/lib/providers/nextor-movil.ts index ac817cf..db2ac98 100644 --- a/src/lib/providers/nextor-movil.ts +++ b/src/lib/providers/nextor-movil.ts @@ -4,104 +4,131 @@ import { getResidentialProxyUrl } from "@/lib/proxy"; import { stripCURPs } from "@/lib/sanitize"; import type { LineResult } from "@/types"; -function getProxyAgent(): ProxyAgent | undefined { +// One agent for the whole lookup, not one per request. +// +// getResidentialProxyUrl() picks a random entry from RESIDENTIAL_PROXIES on +// every call, so resolving it separately for /iniciar and /pre-check could send +// them through two different residential IPs — the sessionId handed out to one +// IP then arrives from another. Against an endpoint whose documented problem is +// per-IP rate limiting, that is the one thing we cannot afford to get wrong. +// +// Reusing a single agent also means one TCP+TLS handshake to the proxy instead +// of two, and lets us apply the same keep-alive posture as the global +// dispatcher in instrumentation.ts — an explicit `dispatcher:` bypasses it, so +// the proxied providers were the only ones not getting that protection. +function createDispatcher(): ProxyAgent | undefined { const proxyUrl = getResidentialProxyUrl(); - return proxyUrl ? new ProxyAgent(proxyUrl) : undefined; + if (!proxyUrl) return undefined; + + return new ProxyAgent({ + uri: proxyUrl, + allowH2: false, + keepAliveTimeout: 10_000, + keepAliveMaxTimeout: 10_000, + }); } export async function lookupCURPINNextorMovil( curp: string, ): Promise { - const authResponse = await undiciFetch( - "https://vinculacion.nextormovil.mx/api/consulta/iniciar", - { - signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), - method: "POST", - dispatcher: getProxyAgent(), - }, - ); - - if (!authResponse.ok) { - const errorData = (await authResponse.json()) as { code?: string }; - - if (errorData.code === "IP_RATE_LIMIT") { - console.warn( - "Nextor Movil rate limit hit. Returning rate limit error.", - errorData, - ); + const dispatcher = createDispatcher(); + + try { + const authResponse = await undiciFetch( + "https://vinculacion.nextormovil.mx/api/consulta/iniciar", + { + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), + method: "POST", + dispatcher, + }, + ); + + if (!authResponse.ok) { + const errorData = (await authResponse.json()) as { code?: string }; + + if (errorData.code === "IP_RATE_LIMIT") { + console.warn( + "Nextor Movil rate limit hit. Returning rate limit error.", + errorData, + ); + return { + company: "Nextor Movil", + lines: [], + error: "Nextor Movil rate limit exceeded. Please try again later.", + }; + } + return { company: "Nextor Movil", lines: [], - error: "Nextor Movil rate limit exceeded. Please try again later.", + error: "Failed to initiate session with Nextor Movil", }; } - return { - company: "Nextor Movil", - lines: [], - error: "Failed to initiate session with Nextor Movil", + const authData = (await authResponse.json()) as { sessionId?: string }; + const sessionId = authData.sessionId; + + const validationBody = { + tipo: "curp", + valor: curp, + }; + + const validationHeaders = { + "X-Session-Id": sessionId, + "Content-Type": "application/json", }; - } - const authData = (await authResponse.json()) as { sessionId?: string }; - const sessionId = authData.sessionId; - - const validationBody = { - tipo: "curp", - valor: curp, - }; - - const validationHeaders = { - "X-Session-Id": sessionId, - "Content-Type": "application/json", - }; - - const validationResponse = await undiciFetch( - "https://vinculacion.nextormovil.mx/api/consulta/pre-check", - { - signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), - method: "POST", - headers: validationHeaders, - body: JSON.stringify(validationBody), - dispatcher: getProxyAgent(), - }, - ); - - if (!validationResponse.ok) { - const errorBody = await validationResponse - .text() - .catch(() => "(unreadable)"); - console.error( - `Failed to validate CURP with Nextor Movil: ${validationResponse.status} ${validationResponse.statusText} — body: ${errorBody}`, + const validationResponse = await undiciFetch( + "https://vinculacion.nextormovil.mx/api/consulta/pre-check", + { + signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS), + method: "POST", + headers: validationHeaders, + body: JSON.stringify(validationBody), + dispatcher, + }, ); - return { - company: "Nextor Movil", - lines: [], - error: "Failed to validate CURP with Nextor Movil", + if (!validationResponse.ok) { + const errorBody = await validationResponse + .text() + .catch(() => "(unreadable)"); + console.error( + `Failed to validate CURP with Nextor Movil: ${validationResponse.status} ${validationResponse.statusText} — body: ${errorBody}`, + ); + + return { + company: "Nextor Movil", + lines: [], + error: "Failed to validate CURP with Nextor Movil", + }; + } + + const validationData = (await validationResponse.json()) as { + encontrado?: boolean; }; - } - const validationData = (await validationResponse.json()) as { - encontrado?: boolean; - }; + if (validationData.encontrado) { + console.log( + "[nextor-movil] registered response:", + JSON.stringify(stripCURPs(validationData), null, 2), + ); + return { + company: "Nextor Movil", + lines: [], + isRegistered: true, + rawApiResponse: validationData, + }; + } - if (validationData.encontrado) { - console.log( - "[nextor-movil] registered response:", - JSON.stringify(stripCURPs(validationData), null, 2), - ); return { company: "Nextor Movil", lines: [], - isRegistered: true, - rawApiResponse: validationData, + isRegistered: false, }; + } finally { + // Without this the agent's sockets stay open until they idle out, so a + // burst of lookups piles up proxy connections nobody is using. + await dispatcher?.close(); } - - return { - company: "Nextor Movil", - lines: [], - isRegistered: false, - }; }