From 4de68dc996ed9fd2ca4088006675cc40c37a525e Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:59:03 -0300 Subject: [PATCH] resuelto el problema, el post para crear customer creaba pero no emitia respuesta, se deja en 2 segundos y se usa un get por email para traer la data --- index.js | 20 ++ package-lock.json | 10 + package.json | 1 + src/db/repo.js | 13 +- src/handlers/evolution.js | 19 +- src/services/evolutionParser.js | 1 - src/services/pipeline.js | 115 +++++++--- src/services/woo.js | 383 +++++++++++++++++++++++++++----- 8 files changed, 475 insertions(+), 87 deletions(-) diff --git a/index.js b/index.js index 029fc43..991bb93 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,25 @@ import { makeSimSend } from "./src/controllers/sim.js"; import { makeEvolutionWebhook } from "./src/controllers/evolution.js"; import { makeGetConversationState } from "./src/controllers/conversationState.js"; +async function configureUndiciDispatcher() { + // Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling. + // Nota: si el módulo `undici` no está disponible, no rompemos el arranque (solo logueamos warning). + try { + const { setGlobalDispatcher, Agent } = await import("undici"); + setGlobalDispatcher( + new Agent({ + connections: 10, + pipelining: 0, + keepAliveTimeout: 10_000, + keepAliveMaxTimeout: 10_000, + }) + ); + console.log("[http] undici global dispatcher configured"); + } catch (e) { + console.warn("[http] undici dispatcher not configured:", e?.message || e); + } +} + const app = express(); app.use(cors()); app.use(express.json({ limit: "1mb" })); @@ -73,6 +92,7 @@ app.get("/", (req, res) => { const port = process.env.PORT || 3000; (async function boot() { + await configureUndiciDispatcher(); TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() }); app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`)); })().catch((err) => { diff --git a/package-lock.json b/package-lock.json index 69029a3..881afbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "express": "^4.19.2", "openai": "^6.15.0", "pg": "^8.16.3", + "undici": "^7.16.0", "zod": "^4.3.4" }, "devDependencies": { @@ -1488,6 +1489,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 343f111..afb2047 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "express": "^4.19.2", "openai": "^6.15.0", "pg": "^8.16.3", + "undici": "^7.16.0", "zod": "^4.3.4" }, "devDependencies": { diff --git a/src/db/repo.js b/src/db/repo.js index eca4848..482ddf6 100644 --- a/src/db/repo.js +++ b/src/db/repo.js @@ -135,6 +135,17 @@ export async function insertRun({ return rows[0]?.id || null; } +export async function updateRunLatency({ tenant_id, run_id, latency_ms }) { + if (!tenant_id || !run_id) return false; + const q = ` + update conversation_runs + set latency_ms = $3 + where tenant_id = $1 and id = $2 + `; + await pool.query(q, [tenant_id, run_id, latency_ms]); + return true; +} + export async function listConversations({ tenant_id, q = "", status = "", state = "", limit = 50 }) { const params = [tenant_id]; let where = `where tenant_id=$1`; @@ -301,7 +312,7 @@ export async function upsertExternalCustomerMap({ const q = ` insert into wa_identity_map (tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at) values ($1, $2, $3, $4, now(), now()) - on conflict (tenant_id, wa_chat_id, provider) + on conflict (tenant_id, wa_chat_id) do update set external_customer_id = excluded.external_customer_id, updated_at = now() returning external_customer_id `; diff --git a/src/handlers/evolution.js b/src/handlers/evolution.js index 734df03..d0fa4bd 100644 --- a/src/handlers/evolution.js +++ b/src/handlers/evolution.js @@ -3,27 +3,44 @@ import { parseEvolutionWebhook } from "../services/evolutionParser.js"; import { resolveTenantId, processMessage } from "../services/pipeline.js"; export async function handleEvolutionWebhook(body) { + const t0 = Date.now(); const parsed = parseEvolutionWebhook(body); if (!parsed.ok) { return { status: 200, payload: { ok: true, ignored: parsed.reason } }; } + console.log("[perf] evolution.webhook.start", { + tenant_key: parsed.tenant_key || null, + chat_id: parsed.chat_id, + message_id: parsed.message_id || null, + ts: parsed.ts || null, + }); + const tenantId = await resolveTenantId({ chat_id: parsed.chat_id, tenant_key: parsed.tenant_key, to_phone: null, }); - await processMessage({ + const pm = await processMessage({ tenantId, chat_id: parsed.chat_id, from: parsed.chat_id.replace("@s.whatsapp.net", ""), + displayName: parsed.from_name || null, text: parsed.text, provider: "evolution", message_id: parsed.message_id || crypto.randomUUID(), meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key }, }); + console.log("[perf] evolution.webhook.end", { + tenantId, + chat_id: parsed.chat_id, + message_id: parsed.message_id || null, + run_id: pm?.run_id || null, + webhook_ms: Date.now() - t0, + }); + return { status: 200, payload: { ok: true } }; } diff --git a/src/services/evolutionParser.js b/src/services/evolutionParser.js index c482b67..27cb138 100644 --- a/src/services/evolutionParser.js +++ b/src/services/evolutionParser.js @@ -1,5 +1,4 @@ export function parseEvolutionWebhook(reqBody) { - // n8n a veces entrega array con { body }, en express real suele ser directo const envelope = Array.isArray(reqBody) ? reqBody[0] : reqBody; const body = envelope?.body ?? envelope ?? {}; diff --git a/src/services/pipeline.js b/src/services/pipeline.js index cb69dae..afc9694 100644 --- a/src/services/pipeline.js +++ b/src/services/pipeline.js @@ -7,6 +7,7 @@ import { getRecentMessagesForLLM, getExternalCustomerIdByChat, upsertExternalCustomerMap, + updateRunLatency, } from "../db/repo.js"; import { sseSend } from "./sse.js"; import { createWooCustomer, getWooCustomerById } from "./woo.js"; @@ -20,9 +21,30 @@ function newId(prefix = "run") { return `${prefix}_${crypto.randomUUID()}`; } -export async function processMessage({ tenantId, chat_id, from, text, provider, message_id }) { +export async function processMessage({ + tenantId, + chat_id, + from, + text, + provider, + message_id, + displayName = null, +}) { const started_at = Date.now(); + const perf = { t0: started_at, marks: {} }; + const mark = (name) => { + perf.marks[name] = Date.now(); + }; + const msBetween = (a, b) => { + const ta = a === "t0" ? perf.t0 : perf.marks[a]; + const tb = b === "t0" ? perf.t0 : perf.marks[b]; + if (!ta || !tb) return null; + return tb - ta; + }; + + mark("start"); const prev = await getConversationState(tenantId, chat_id); + mark("after_getConversationState"); const isStale = prev?.state_updated_at && Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; @@ -32,6 +54,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, wa_chat_id: chat_id, provider: "woo", }); + mark("after_getExternalCustomerIdByChat"); await insertMessage({ tenant_id: tenantId, @@ -43,6 +66,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, payload: { raw: { from, text } }, run_id: null, }); + mark("after_insertMessage_in"); const plan = { reply: `Recibido: "${text}". ¿Querés retiro o envío?`, @@ -61,8 +85,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, { name: "no_order_action_without_items", ok: true }, ], }; - - const latency_ms = Date.now() - started_at; + mark("before_insertRun"); const run_id = await insertRun({ tenant_id: tenantId, @@ -75,8 +98,9 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, invariants, final_reply: plan.reply, status: "ok", - latency_ms, + latency_ms: null, // se actualiza al final con end-to-end real }); + mark("after_insertRun"); const outMessageId = newId("out"); await insertMessage({ @@ -89,26 +113,38 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, payload: { reply: plan.reply }, run_id, }); + mark("after_insertMessage_out"); - // Si no tenemos cliente Woo mapeado, creamos uno (stub) y guardamos el mapping. + mark("before_ensureWooCustomer"); if (externalCustomerId) { - // validar existencia en Woo; si no existe, lo recreamos const found = await getWooCustomerById({ tenantId, id: externalCustomerId }); if (!found) { const phone = chat_id.replace(/@.+$/, ""); - const name = from || phone; + const name = displayName || from || phone; const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); + console.log("created", created); + if (!created?.id) throw new Error("woo_customer_id_missing"); externalCustomerId = await upsertExternalCustomerMap({ tenant_id: tenantId, wa_chat_id: chat_id, external_customer_id: created?.id, provider: "woo", }); + } else { + // Asegurar que el mapping exista/esté actualizado + externalCustomerId = await upsertExternalCustomerMap({ + tenant_id: tenantId, + wa_chat_id: chat_id, + external_customer_id: externalCustomerId, + provider: "woo", + }); } } else { const phone = chat_id.replace(/@.+$/, ""); - const name = from || phone; + const name = displayName || from || phone; const created = await createWooCustomer({ tenantId, wa_chat_id: chat_id, phone, name }); + console.log("created", created); + if (!created?.id) throw new Error("woo_customer_id_missing"); externalCustomerId = await upsertExternalCustomerMap({ tenant_id: tenantId, wa_chat_id: chat_id, @@ -116,6 +152,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, provider: "woo", }); } + mark("after_ensureWooCustomer"); const context = { missing_fields: plan.missing_fields || [], @@ -131,6 +168,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, last_order_id: null, context, }); + mark("after_upsertConversationState"); sseSend("conversation.upsert", { chat_id: stateRow.wa_chat_id, @@ -142,6 +180,31 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, last_run_id: run_id, }); + const history = await getRecentMessagesForLLM({ + tenant_id: tenantId, + wa_chat_id: chat_id, + limit: 20, + }); + const compactHistory = collapseAssistantMessages(history); + mark("after_getRecentMessagesForLLM"); + + const llmInput = { + wa_chat_id: chat_id, + last_user_message: text, + conversation_history: compactHistory, + current_conversation_state: prev_state, + context: { + ...(prev?.context || {}), + external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, + }, + }; + void llmInput; + + const end_to_end_ms = Date.now() - started_at; + if (run_id) { + await updateRunLatency({ tenant_id: tenantId, run_id, latency_ms: end_to_end_ms }); + } + sseSend("run.created", { run_id, ts: nowIso(), @@ -156,26 +219,28 @@ export async function processMessage({ tenantId, chat_id, from, text, provider, final_reply: plan.reply, order_id: null, payment_link: null, - latency_ms, + latency_ms: end_to_end_ms, }); - const history = await getRecentMessagesForLLM({ - tenant_id: tenantId, - wa_chat_id: chat_id, - limit: 20, - }); - const compactHistory = collapseAssistantMessages(history); - - const llmInput = { - wa_chat_id: chat_id, - last_user_message: text, - conversation_history: compactHistory, - current_conversation_state: prev_state, - context: { - ...(prev?.context || {}), - external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, + // Log de performance end-to-end + tramos principales (para diagnosticar "dónde se va el tiempo") + console.log("[perf] processMessage", { + tenantId, + chat_id, + provider, + message_id, + run_id, + end_to_end_ms, + ms: { + db_state_ms: msBetween("start", "after_getConversationState"), + db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"), + insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"), + insert_run_ms: msBetween("before_insertRun", "after_insertRun"), + insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"), + woo_customer_ms: msBetween("before_ensureWooCustomer", "after_ensureWooCustomer"), + upsert_state_ms: msBetween("after_ensureWooCustomer", "after_upsertConversationState"), + history_ms: msBetween("after_upsertConversationState", "after_getRecentMessagesForLLM"), }, - }; + }); return { run_id, reply: plan.reply }; } diff --git a/src/services/woo.js b/src/services/woo.js index b20d2f6..ace5b96 100644 --- a/src/services/woo.js +++ b/src/services/woo.js @@ -1,76 +1,341 @@ import crypto from "crypto"; import { getDecryptedTenantEcommerceConfig } from "../db/repo.js"; -async function fetchWoo({ url, method = "GET", body = null, timeout = 8000 }) { - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), timeout); +// --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) --- +const locks = new Map(); + +async function withLock(key, fn) { + const prev = locks.get(key) || Promise.resolve(); + let release; + const next = new Promise((r) => (release = r)); + locks.set(key, prev.then(() => next)); + + const queuedAt = Date.now(); + await prev; + const acquiredAt = Date.now(); try { - const res = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: body ? JSON.stringify(body) : null, - signal: controller.signal, - }); - const text = await res.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch { - // ignore parse error - } - if (!res.ok) { - const err = new Error(`Woo request failed: ${res.status}`); - err.status = res.status; - err.body = json || text; - throw err; - } - return json; + return await fn({ lock_wait_ms: acquiredAt - queuedAt }); } finally { - clearTimeout(t); + release(); + // cleanup + if (locks.get(key) === next) locks.delete(key); } } -export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) { - const encryptionKey = process.env.APP_ENCRYPTION_KEY; - if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials"); +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} - const cfg = await getDecryptedTenantEcommerceConfig({ - tenant_id: tenantId, - provider: "woo", - encryption_key: encryptionKey, +function getDeep(obj, path) { + let cur = obj; + for (const k of path) { + cur = cur?.[k]; + } + return cur; +} + +function isRetryableNetworkError(err) { + // fetchWoo wraps errors as { message: "...", cause: originalError } + const e0 = err; + const e1 = err?.cause; + const e2 = getDeep(err, ["cause", "cause"]); + + const candidates = [e0, e1, e2].filter(Boolean); + const codes = new Set( + candidates.map((e) => e.code).filter(Boolean) + ); + const names = new Set( + candidates.map((e) => e.name).filter(Boolean) + ); + const messages = candidates.map((e) => String(e.message || "")).join(" | ").toLowerCase(); + + const aborted = + names.has("AbortError") || + messages.includes("aborted") || + messages.includes("timeout") || + messages.includes("timed out"); + + // ECONNRESET/ETIMEDOUT are common; undici may also surface UND_ERR_* codes + const retryCodes = new Set(["ECONNRESET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]); + const byCode = [...codes].some((c) => retryCodes.has(c)); + + return aborted || byCode; +} + +async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("timeout")), timeout); + const t0 = Date.now(); + try { + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + console.log("woo headers in", Date.now() - t0, "ms", res.status); + const text = await res.text(); + console.log("woo body in", Date.now() - t0, "ms", "len", text.length); + let parsed; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + if (!res.ok) { + const err = new Error(`Woo HTTP ${res.status}`); + err.status = res.status; + err.body = parsed; + err.url = url; + err.method = method; + throw err; + } + return parsed; + } catch (e) { + const err = new Error(`Woo request failed after ${Date.now() - t0}ms: ${e.message}`); + err.cause = e; + err.url = url; + err.method = method; + throw err; + } finally { + clearTimeout(timer); + } +} + +async function searchWooCustomerByEmail({ base, consumerKey, consumerSecret, email, timeout }) { + const url = `${base}/customers?email=${encodeURIComponent(email)}`; + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64"); + const data = await fetchWoo({ + url, + method: "GET", + timeout, + headers: { Authorization: `Basic ${auth}` }, }); - if (!cfg) throw new Error("Woo config not found for tenant"); + if (Array.isArray(data) && data.length > 0) return data[0]; + return null; +} - const consumerKey = - cfg.consumer_key || - process.env.WOO_CONSUMER_KEY || - (() => { - throw new Error("consumer_key not set"); - })(); - const consumerSecret = - cfg.consumer_secret || - process.env.WOO_CONSUMER_SECRET || - (() => { - throw new Error("consumer_secret not set"); - })(); +async function searchWooCustomerByUsername({ base, consumerKey, consumerSecret, username, timeout }) { + const url = `${base}/customers?search=${encodeURIComponent(username)}`; + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64"); + const data = await fetchWoo({ + url, + method: "GET", + timeout, + headers: { Authorization: `Basic ${auth}` }, + }); + if (Array.isArray(data) && data.length > 0) { + const exact = data.find((c) => c.username === username); + return exact || data[0]; + } + return null; +} - const base = cfg.base_url.replace(/\/+$/, ""); - const url = `${base}/customers?consumer_key=${encodeURIComponent( - consumerKey - )}&consumer_secret=${encodeURIComponent(consumerSecret)}`; +export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) { + const lockKey = `${tenantId}:${wa_chat_id}`; + const t0 = Date.now(); + return withLock(lockKey, async ({ lock_wait_ms }) => { + const encryptionKey = process.env.APP_ENCRYPTION_KEY; + if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials"); - const payload = { - email: `${phone || wa_chat_id}@no-email.local`, - first_name: name || phone || wa_chat_id, - username: phone || wa_chat_id, - password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo - billing: { - phone: phone || wa_chat_id, - }, - }; + const cfg = await getDecryptedTenantEcommerceConfig({ + tenant_id: tenantId, + provider: "woo", + encryption_key: encryptionKey, + }); + if (!cfg) throw new Error("Woo config not found for tenant"); - const data = await fetchWoo({ url, method: "POST", body: payload, timeout: cfg.timeout_ms }); - return { id: data?.id, raw: data }; + const consumerKey = + cfg.consumer_key || + process.env.WOO_CONSUMER_KEY || + (() => { + throw new Error("consumer_key not set"); + })(); + const consumerSecret = + cfg.consumer_secret || + process.env.WOO_CONSUMER_SECRET || + (() => { + throw new Error("consumer_secret not set"); + })(); + + const base = cfg.base_url.replace(/\/+$/, ""); + const email = `${phone || wa_chat_id}@no-email.local`; + const username = wa_chat_id; + const timeout = Math.max(cfg.timeout_ms ?? 20000, 20000); + const getTimeout = timeout; + const postTimeout = 2000; + + // Para existencia/idempotencia: email primero (más estable y liviano que search=...) + const existing = await searchWooCustomerByEmail({ + base, + consumerKey, + consumerSecret, + email, + timeout: getTimeout, + }); + if (existing?.id) { + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: true, + reason: "email_hit", + lock_wait_ms, + total_ms, + }); + return { id: existing.id, raw: existing, reused: true }; + } + + // Fallback (solo por compatibilidad): si alguien creó el customer con username pero email distinto + const existingByUser = await searchWooCustomerByUsername({ + base, + consumerKey, + consumerSecret, + username, + timeout: getTimeout, + }); + if (existingByUser?.id) { + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: true, + reason: "username_hit", + lock_wait_ms, + total_ms, + }); + return { id: existingByUser.id, raw: existingByUser, reused: true }; + } + + const url = `${base}/customers`; + const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64"); + + const payload = { + email, + first_name: name || phone || wa_chat_id, + username, + password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo + billing: { + phone: phone || wa_chat_id, + }, + }; + + const doPost = async () => + await fetchWoo({ + url, + method: "POST", + body: payload, + timeout: postTimeout, + headers: { Authorization: `Basic ${auth}` }, + }); + + try { + const data = await doPost(); + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: false, + reason: "post_ok", + lock_wait_ms, + total_ms, + }); + return { id: data?.id, raw: data, reused: false }; + } catch (err) { + // Caso estándar: Woo dice "email exists" => recuperar por email y devolver + if (err.status === 400 && err.body?.code === "registration-error-email-exists") { + const found = await searchWooCustomerByEmail({ + base, + consumerKey, + consumerSecret, + email, + timeout: getTimeout, + }); + if (found?.id) { + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: true, + reason: "email_exists_recovered", + lock_wait_ms, + total_ms, + }); + return { id: found.id, raw: found, reused: true }; + } + } + + // Retry seguro solo para timeouts / ECONNRESET: + // POST pudo haber creado el customer pero la respuesta no volvió => hacer GET por email. + if (isRetryableNetworkError(err)) { + await sleep(300 + Math.floor(Math.random() * 300)); + + const foundAfterTimeout = await searchWooCustomerByEmail({ + base, + consumerKey, + consumerSecret, + email, + timeout: getTimeout, + }); + if (foundAfterTimeout?.id) { + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: true, + reason: "timeout_get_recovered", + lock_wait_ms, + total_ms, + }); + return { id: foundAfterTimeout.id, raw: foundAfterTimeout, reused: true }; + } + + // si no existe, reintentar POST una vez + try { + const data2 = await doPost(); + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: false, + reason: "timeout_post_retry_ok", + lock_wait_ms, + total_ms, + }); + return { id: data2?.id, raw: data2, reused: false }; + } catch (err2) { + // último intento de "recovery" (por si 2do POST también timeouteó) + if (isRetryableNetworkError(err2)) { + const foundAfterTimeout2 = await searchWooCustomerByEmail({ + base, + consumerKey, + consumerSecret, + email, + timeout: getTimeout, + }); + if (foundAfterTimeout2?.id) { + const total_ms = Date.now() - t0; + console.log("[perf] woo.createCustomer", { + tenantId, + wa_chat_id, + reused: true, + reason: "timeout_post_retry_get_recovered", + lock_wait_ms, + total_ms, + }); + return { id: foundAfterTimeout2.id, raw: foundAfterTimeout2, reused: true }; + } + } + throw err2; + } + } + + throw err; + } + }); } export async function getWooCustomerById({ tenantId, id }) {