From bbc26fc778bb447a53478c8f52b07634b5d6fe0a Mon Sep 17 00:00:00 2001 From: Victor Date: Wed, 8 Jul 2026 06:04:47 +0200 Subject: [PATCH] fix(robustez): no dejar al usuario en silencio ante errores del run (reintento + aviso) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug en vivo: un 500 transitorio de OpenAI hacía que el turno fallara y ConversationService relanzaba el error -> el usuario NO recibía nada (silencio). Ahora: ante un error transitorio (5xx/server_error, timeout, red, sobrecarga) se reintenta UNA vez; si vuelve a fallar, se avisa con un mensaje amable. Ante cualquier otro error no controlado, también se avisa en vez de quedar mudo. El caso de historial corrupto sigue reiniciando la sesión como antes. Tests 93->97 (isTransientError, reintento OK, reintento falla, error no transitorio). --- src/application/conversation-service.test.ts | 60 +++++++++++++++++++- src/application/conversation-service.ts | 50 ++++++++++++---- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/application/conversation-service.test.ts b/src/application/conversation-service.test.ts index a0d3f77..364b91c 100644 --- a/src/application/conversation-service.test.ts +++ b/src/application/conversation-service.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert"; -import { ConversationService, MAX_TEXT_LENGTH, isCorruptedHistoryError } from "./conversation-service.js"; +import { ConversationService, MAX_TEXT_LENGTH, isCorruptedHistoryError, isTransientError } from "./conversation-service.js"; import { RateLimiter } from "./rate-limiter.js"; import type { Account } from "../domain/account.js"; import type { MessagingChannel, SelectionOption } from "../domain/ports/messaging-channel.port.js"; @@ -230,3 +230,61 @@ test("ConversationService · con historial, un saludo SÍ va al agente", async ( assert.strictEqual(runCalls, 1, "con contexto en curso, el saludo lo maneja el agente"); }); + +test("isTransientError", () => { + assert.ok(isTransientError("500 server_error")); + assert.ok(isTransientError("503 Service Unavailable overloaded")); + assert.ok(isTransientError("request failed: ETIMEDOUT")); + assert.ok(!isTransientError("400 bad request")); + assert.ok(!isTransientError("No tool call found for function call output")); +}); + +test("ConversationService · reintenta 1 vez ante error transitorio y responde", async () => { + const authStore = makeFakeAuthStore(); + let calls = 0; + const service = new ConversationService( + { getSession: () => makeFakeSession(), authStore, log: () => {} }, + async () => { + calls += 1; + if (calls === 1) throw new Error("500 server_error temporal de OpenAI"); + return { finalOutput: "ya te contesto" } as any; + }, + ); + const { channel, sent } = makeFakeChannel(); + await service.handle({ account, chatId: "901", text: "consulta" }, channel); + assert.strictEqual(calls, 2, "reintenta una vez"); + assert.strictEqual(sent.length, 1); + assert.match(sent[0].text, /ya te contesto/); +}); + +test("ConversationService · si el reintento también falla, avisa sin lanzar", async () => { + const authStore = makeFakeAuthStore(); + let calls = 0; + const service = new ConversationService( + { getSession: () => makeFakeSession(), authStore, log: () => {} }, + async () => { + calls += 1; + throw new Error("503 overloaded"); + }, + ); + const { channel, sent } = makeFakeChannel(); + await service.handle({ account, chatId: "902", text: "consulta" }, channel); // no debe lanzar + assert.strictEqual(calls, 2); + assert.match(sent[0].text, /problema técnico temporal/); +}); + +test("ConversationService · error no transitorio: avisa sin reintentar ni lanzar", async () => { + const authStore = makeFakeAuthStore(); + let calls = 0; + const service = new ConversationService( + { getSession: () => makeFakeSession(), authStore, log: () => {} }, + async () => { + calls += 1; + throw new Error("algo raro y no transitorio"); + }, + ); + const { channel, sent } = makeFakeChannel(); + await service.handle({ account, chatId: "903", text: "consulta" }, channel); + assert.strictEqual(calls, 1, "no reintenta"); + assert.match(sent[0].text, /problema procesando/); +}); diff --git a/src/application/conversation-service.ts b/src/application/conversation-service.ts index 5d26587..4c6746c 100644 --- a/src/application/conversation-service.ts +++ b/src/application/conversation-service.ts @@ -28,6 +28,19 @@ export function isCorruptedHistoryError(message: string): boolean { ); } +/** + * Detecta errores transitorios (temporales) del run que merecen un reintento: + * 5xx / server_error de OpenAI, timeouts, cortes de red, sobrecarga. + */ +export function isTransientError(message: string): boolean { + return ( + /server_error/i.test(message) || + /\b(500|502|503|504)\b/.test(message) || + /overloaded|temporarily unavailable|rate limit|too many requests/i.test(message) || + /timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|network/i.test(message) + ); +} + /** true si la sesión no tiene historial todavía (conversación nueva). */ async function isEmptySession(session: ConversationStore): Promise { try { @@ -130,15 +143,10 @@ export class ConversationService { result = await this.run(apiAgent, userText, { context, session }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - log({ - kind: "error", - ...logBase, - ms: Date.now() - startedAt, - error: message, - }); - // Red de seguridad: si el historial quedó con un par de tool incompleto, - // OpenAI rechaza cada turno y el usuario queda bloqueado. Reiniciamos la - // sesión y le pedimos que repita, en vez de fallar en silencio. + log({ kind: "error", ...logBase, ms: Date.now() - startedAt, error: message }); + + // Historial con un par de tool incompleto: OpenAI rechaza cada turno y el + // usuario queda bloqueado. Reiniciamos la sesión y pedimos repetir. if (isCorruptedHistoryError(message)) { await Promise.resolve( (session as { clearSession?: () => Promise }).clearSession?.(), @@ -149,7 +157,29 @@ export class ConversationService { ); return; } - throw error; + + // Error transitorio (p. ej. 500 de OpenAI, timeout): reintentamos UNA vez. + // Antes esto se relanzaba y el usuario se quedaba sin respuesta (silencio). + if (isTransientError(message)) { + try { + result = await this.run(apiAgent, userText, { context, session }); + } catch (retryError) { + const retryMessage = retryError instanceof Error ? retryError.message : String(retryError); + log({ kind: "error", ...logBase, ms: Date.now() - startedAt, error: `retry-failed: ${retryMessage}` }); + await channel.sendText( + chatId, + "Perdona, estoy teniendo un problema técnico temporal. Inténtalo de nuevo en un momento, por favor.", + ); + return; + } + } else { + // Cualquier otro error: nunca dejar al usuario en silencio. + await channel.sendText( + chatId, + "Perdona, ha habido un problema procesando tu mensaje. Inténtalo de nuevo, por favor.", + ); + return; + } } if (context.authenticatedToken) {