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) {