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

This commit is contained in:
Lucas Tettamanti
2026-01-02 19:59:03 -03:00
parent 303c3daafe
commit 4de68dc996
8 changed files with 475 additions and 87 deletions

View File

@@ -11,6 +11,25 @@ import { makeSimSend } from "./src/controllers/sim.js";
import { makeEvolutionWebhook } from "./src/controllers/evolution.js"; import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
import { makeGetConversationState } from "./src/controllers/conversationState.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(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
@@ -73,6 +92,7 @@ app.get("/", (req, res) => {
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
(async function boot() { (async function boot() {
await configureUndiciDispatcher();
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() }); TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`)); app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
})().catch((err) => { })().catch((err) => {

10
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"express": "^4.19.2", "express": "^4.19.2",
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"undici": "^7.16.0",
"zod": "^4.3.4" "zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {
@@ -1488,6 +1489,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -21,6 +21,7 @@
"express": "^4.19.2", "express": "^4.19.2",
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"undici": "^7.16.0",
"zod": "^4.3.4" "zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -135,6 +135,17 @@ export async function insertRun({
return rows[0]?.id || null; 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 }) { export async function listConversations({ tenant_id, q = "", status = "", state = "", limit = 50 }) {
const params = [tenant_id]; const params = [tenant_id];
let where = `where tenant_id=$1`; let where = `where tenant_id=$1`;
@@ -301,7 +312,7 @@ export async function upsertExternalCustomerMap({
const q = ` const q = `
insert into wa_identity_map (tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at) 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()) 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() do update set external_customer_id = excluded.external_customer_id, updated_at = now()
returning external_customer_id returning external_customer_id
`; `;

View File

@@ -3,27 +3,44 @@ import { parseEvolutionWebhook } from "../services/evolutionParser.js";
import { resolveTenantId, processMessage } from "../services/pipeline.js"; import { resolveTenantId, processMessage } from "../services/pipeline.js";
export async function handleEvolutionWebhook(body) { export async function handleEvolutionWebhook(body) {
const t0 = Date.now();
const parsed = parseEvolutionWebhook(body); const parsed = parseEvolutionWebhook(body);
if (!parsed.ok) { if (!parsed.ok) {
return { status: 200, payload: { ok: true, ignored: parsed.reason } }; 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({ const tenantId = await resolveTenantId({
chat_id: parsed.chat_id, chat_id: parsed.chat_id,
tenant_key: parsed.tenant_key, tenant_key: parsed.tenant_key,
to_phone: null, to_phone: null,
}); });
await processMessage({ const pm = await processMessage({
tenantId, tenantId,
chat_id: parsed.chat_id, chat_id: parsed.chat_id,
from: parsed.chat_id.replace("@s.whatsapp.net", ""), from: parsed.chat_id.replace("@s.whatsapp.net", ""),
displayName: parsed.from_name || null,
text: parsed.text, text: parsed.text,
provider: "evolution", provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(), message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key }, 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 } }; return { status: 200, payload: { ok: true } };
} }

View File

@@ -1,5 +1,4 @@
export function parseEvolutionWebhook(reqBody) { 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 envelope = Array.isArray(reqBody) ? reqBody[0] : reqBody;
const body = envelope?.body ?? envelope ?? {}; const body = envelope?.body ?? envelope ?? {};

View File

@@ -7,6 +7,7 @@ import {
getRecentMessagesForLLM, getRecentMessagesForLLM,
getExternalCustomerIdByChat, getExternalCustomerIdByChat,
upsertExternalCustomerMap, upsertExternalCustomerMap,
updateRunLatency,
} from "../db/repo.js"; } from "../db/repo.js";
import { sseSend } from "./sse.js"; import { sseSend } from "./sse.js";
import { createWooCustomer, getWooCustomerById } from "./woo.js"; import { createWooCustomer, getWooCustomerById } from "./woo.js";
@@ -20,9 +21,30 @@ function newId(prefix = "run") {
return `${prefix}_${crypto.randomUUID()}`; 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 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); const prev = await getConversationState(tenantId, chat_id);
mark("after_getConversationState");
const isStale = const isStale =
prev?.state_updated_at && prev?.state_updated_at &&
Date.now() - new Date(prev.state_updated_at).getTime() > 24 * 60 * 60 * 1000; 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, wa_chat_id: chat_id,
provider: "woo", provider: "woo",
}); });
mark("after_getExternalCustomerIdByChat");
await insertMessage({ await insertMessage({
tenant_id: tenantId, tenant_id: tenantId,
@@ -43,6 +66,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
payload: { raw: { from, text } }, payload: { raw: { from, text } },
run_id: null, run_id: null,
}); });
mark("after_insertMessage_in");
const plan = { const plan = {
reply: `Recibido: "${text}". ¿Querés retiro o envío?`, 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 }, { name: "no_order_action_without_items", ok: true },
], ],
}; };
mark("before_insertRun");
const latency_ms = Date.now() - started_at;
const run_id = await insertRun({ const run_id = await insertRun({
tenant_id: tenantId, tenant_id: tenantId,
@@ -75,8 +98,9 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
invariants, invariants,
final_reply: plan.reply, final_reply: plan.reply,
status: "ok", status: "ok",
latency_ms, latency_ms: null, // se actualiza al final con end-to-end real
}); });
mark("after_insertRun");
const outMessageId = newId("out"); const outMessageId = newId("out");
await insertMessage({ await insertMessage({
@@ -89,26 +113,38 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
payload: { reply: plan.reply }, payload: { reply: plan.reply },
run_id, run_id,
}); });
mark("after_insertMessage_out");
// Si no tenemos cliente Woo mapeado, creamos uno (stub) y guardamos el mapping. mark("before_ensureWooCustomer");
if (externalCustomerId) { if (externalCustomerId) {
// validar existencia en Woo; si no existe, lo recreamos
const found = await getWooCustomerById({ tenantId, id: externalCustomerId }); const found = await getWooCustomerById({ tenantId, id: externalCustomerId });
if (!found) { if (!found) {
const phone = chat_id.replace(/@.+$/, ""); 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 }); 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({ externalCustomerId = await upsertExternalCustomerMap({
tenant_id: tenantId, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
external_customer_id: created?.id, external_customer_id: created?.id,
provider: "woo", 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 { } else {
const phone = chat_id.replace(/@.+$/, ""); 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 }); 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({ externalCustomerId = await upsertExternalCustomerMap({
tenant_id: tenantId, tenant_id: tenantId,
wa_chat_id: chat_id, wa_chat_id: chat_id,
@@ -116,6 +152,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
provider: "woo", provider: "woo",
}); });
} }
mark("after_ensureWooCustomer");
const context = { const context = {
missing_fields: plan.missing_fields || [], missing_fields: plan.missing_fields || [],
@@ -131,6 +168,7 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
last_order_id: null, last_order_id: null,
context, context,
}); });
mark("after_upsertConversationState");
sseSend("conversation.upsert", { sseSend("conversation.upsert", {
chat_id: stateRow.wa_chat_id, 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, 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", { sseSend("run.created", {
run_id, run_id,
ts: nowIso(), ts: nowIso(),
@@ -156,26 +219,28 @@ export async function processMessage({ tenantId, chat_id, from, text, provider,
final_reply: plan.reply, final_reply: plan.reply,
order_id: null, order_id: null,
payment_link: null, payment_link: null,
latency_ms, latency_ms: end_to_end_ms,
}); });
const history = await getRecentMessagesForLLM({ // Log de performance end-to-end + tramos principales (para diagnosticar "dónde se va el tiempo")
tenant_id: tenantId, console.log("[perf] processMessage", {
wa_chat_id: chat_id, tenantId,
limit: 20, chat_id,
}); provider,
const compactHistory = collapseAssistantMessages(history); message_id,
run_id,
const llmInput = { end_to_end_ms,
wa_chat_id: chat_id, ms: {
last_user_message: text, db_state_ms: msBetween("start", "after_getConversationState"),
conversation_history: compactHistory, db_identity_ms: msBetween("after_getConversationState", "after_getExternalCustomerIdByChat"),
current_conversation_state: prev_state, insert_in_ms: msBetween("after_getExternalCustomerIdByChat", "after_insertMessage_in"),
context: { insert_run_ms: msBetween("before_insertRun", "after_insertRun"),
...(prev?.context || {}), insert_out_ms: msBetween("after_insertRun", "after_insertMessage_out"),
external_customer_id: externalCustomerId ?? prev?.context?.external_customer_id ?? null, 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 }; return { run_id, reply: plan.reply };
} }

View File

@@ -1,36 +1,143 @@
import crypto from "crypto"; import crypto from "crypto";
import { getDecryptedTenantEcommerceConfig } from "../db/repo.js"; import { getDecryptedTenantEcommerceConfig } from "../db/repo.js";
async function fetchWoo({ url, method = "GET", body = null, timeout = 8000 }) { // --- Simple in-memory lock to serialize work per key (e.g. wa_chat_id) ---
const controller = new AbortController(); const locks = new Map();
const t = setTimeout(() => controller.abort(), timeout);
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 { try {
const res = await fetch(url, { return await fn({ lock_wait_ms: acquiredAt - queuedAt });
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;
} finally { } finally {
clearTimeout(t); release();
// cleanup
if (locks.get(key) === next) locks.delete(key);
} }
} }
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
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 (Array.isArray(data) && data.length > 0) return data[0];
return null;
}
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;
}
export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) { 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; const encryptionKey = process.env.APP_ENCRYPTION_KEY;
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials"); if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
@@ -55,22 +162,180 @@ export async function createWooCustomer({ tenantId, wa_chat_id, phone, name }) {
})(); })();
const base = cfg.base_url.replace(/\/+$/, ""); const base = cfg.base_url.replace(/\/+$/, "");
const url = `${base}/customers?consumer_key=${encodeURIComponent( const email = `${phone || wa_chat_id}@no-email.local`;
consumerKey const username = wa_chat_id;
)}&consumer_secret=${encodeURIComponent(consumerSecret)}`; 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 = { const payload = {
email: `${phone || wa_chat_id}@no-email.local`, email,
first_name: name || phone || wa_chat_id, first_name: name || phone || wa_chat_id,
username: phone || wa_chat_id, username,
password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo password: crypto.randomBytes(12).toString("base64url"), // requerido por Woo
billing: { billing: {
phone: phone || wa_chat_id, phone: phone || wa_chat_id,
}, },
}; };
const data = await fetchWoo({ url, method: "POST", body: payload, timeout: cfg.timeout_ms }); const doPost = async () =>
return { id: data?.id, raw: data }; 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 }) { export async function getWooCustomerById({ tenantId, id }) {