Guía10 min

Timeouts y cancelación de llamadas LLM: AbortSignal, no un setTimeout decorativo

Resumen

El timeout de una llamada al modelo no es el de una tool HTTP. OpenAI SDK espera 10 min y reintenta timeouts; Claude corta no-stream a 10 min y devuelve 504. Esta guía fija techo por turno, abort real con signal, y por qué Promise.race deja el socket vivo. Distinto de AbortSignal en tools y de retry.

OpenAIAnthropic
Un reloj recorta una llamada al modelo mientras el signal de abort corta el socket

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

Un agente “colgado” casi nunca está pensando. Está esperando al modelo con el default del SDK: 10 minutos. El README de openai-node (HTTP 200, 2026-09-06) lo dice: Requests time out after 10 minutes by default y al vencer lanza APIConnectionTimeoutError. Peor: requests which time out will be retried twice by default. Tres intentos × 10 min no es resiliencia: es un webhook muerto.

Esto no es timeouts en tools HTTP: esa guía corta un fetch de una tool. Tampoco es reintentos (misma llamada, otro intento) ni fallback de modelos (otro cerebro). Aquí el objeto es el techo de la llamada al LLM y cómo abortarla de verdad.

Contrato: timeout del SDK < timeout del host. signal en cada create/stream. Cero Promise.race que deja el socket. Timeout ≠ “no pasó”: el proveedor pudo generar tokens.

Tres relojes, un turno

RelojQuién lo poneQué cortaQué no corta
Tool HTTPAbortSignal.timeout en el fetch de la toolEl GET/POST de esa toolLa llamada al modelo
LLM SDKtimeout del cliente / por requestHeaders + body de esta generaciónLas tools ya disparadas
HostmaxDuration, Workers CPU, docker stopEl proceso enteroNada con gracia: 137

El FAQ de tools lo deja explícito: ¿Timeout del modelo (max_tokens / SDK)? Otra capa. Esta es esa capa.

Claude Platform (API errors, HTTP 200, 2026-09-06): 504 timeout_errorThe request timed out while processing. Consider using the streaming Messages API. Sección Long requests: evita max_tokens grande sin stream; las redes dropean conexiones idle; los SDKs validan que un Messages no-stream no se espere > 10 min y activan TCP keep-alive.

Streaming de Claude (Streaming messages): especially useful for requests with large max_tokens values, where the SDKs require streaming to avoid HTTP timeouts. Puedes streamear hacia el SDK y devolver el Message completo si no pintas tokens.

Cadena timeout SDK, stream y host

El default de 10 minutos es un bug de producto

openai-node README, bloque Timeouts:

const client = new OpenAI({
  timeout: 20_000, // default is 10 minutes
});

await client.chat.completions.create(
  { model: "gpt-5.5", messages },
  { timeout: 45_000 },
);

client.ts (mismo repo, 2026-09-06): request timeouts are retried by default, so in a worst-case scenario you may wait much longer than this timeout. Y: Node fetch impone headersTimeout / bodyTimeout independientes, típico 5 min, aunque tu timeout sea mayor. Para subirlos hay que pasar undici Agent. Si tu techo es 45 s, no toques undici: el SDK corta antes.

Retries del SDK: 408, conexión, 429, ≥500, y timeouts, 2 veces por default. Un agente en webhook necesita maxRetries: 0 en la llamada al modelo y su propia política (reintentos). Si no, el SDK reintenta detrás de tu breaker.

Número de partida:

SuperficieTechoPor qué
Chat / ack 3 s (Discord)2–8 s al primer token; stream el restoEl ack no es la generación
Tool-calling corto20–45 sUn JSON de tool no necesita 10 min
Reasoning / thinkingel del producto, explícitoLos tokens internos cuentan como output
Batch / offlinestream o Batches APIClaude lo pide para > 10 min

max_tokens no es un timeout. Es un techo de salida. Un modelo lento con max_tokens: 128000 y stream: false es un 504 o un idle drop.

Abort de verdad: signal, no Promise.race

MDN: AbortSignal.timeout(n) aborta a los n ms con TimeoutError. openai-node acepta options.signal por request (el client.ts combina el signal del caller con el controller interno). Eso aborta el fetch. Un race no:

// MAL: el modelo sigue generando; el await “ganó”
const out = await Promise.race([
  client.responses.create({ model, input }),
  sleep(20_000).then(() => { throw new Error("timeout"); }),
]);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 45_000);
try {
  return await client.responses.create(
    { model, input, stream: true },
    { signal: ac.signal, timeout: 45_000, maxRetries: 0 },
  );
} catch (e) {
  if (e?.name === "APIUserAbortError" || e?.name === "APIConnectionTimeoutError") {
    throw new Error("llm_timeout");
  }
  throw e;
} finally {
  clearTimeout(t);
}

Si el harness ya trae ctx.signal (humano pulsó stop), no lo pises: AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)]) cuando exista, igual que en tools. Stop del usuario y techo de pared son dos señales.

Streaming: abortar el ReadableStream / stream.controller.abort() deja de leer. No asumas que el proveedor deja de facturar el tramo ya generado. Logueá request_id (x-request-id / _request_id en openai-node; request_id en Claude). Sin ID no hay disputa.

Abort con signal frente a un race que deja el socket

Qué hacer con el error

ErrorAcciónNo hagas
APIConnectionTimeoutError / 504 timeout_error1 retry con jitter o fallback de modelo3 retries del SDK × 10 min
User abort (AbortError / APIUserAbortError)Cortar el turno; no retryTratarlo como 503
Idle drop mid-streamClaude documenta resume del stream en 4.5 y anterioresRehacer el prompt entero a ciegas
401 / 403 / spendFallar cerradoTimeout no arregla auth
Timeout + tool ya ejecutadaIdempotencia; no re-disparar“El modelo no contestó, repito el POST”

El circuit breaker cuenta timeouts persistentes de esa dependencia. Un abort de usuario no alimenta el breaker. Un 504 repetido sí.

Tokens: un timeout después del primer byte ya consumió output. El log lleva usage parcial si el stream lo dio; si no, marca billed_unknown. No le mientas al presupuesto del tenant.

Checklist

  • Cliente LLM con timeout explícito, no 600_000 ms.
  • maxRetries: 0 en la llamada; retry lo pone tu política.
  • signal por turno, combinado con stop del usuario.
  • stream: true si max_tokens o thinking pueden pasar de ~1 min.
  • Primer token tiene techo más corto que el techo total.
  • Catch tipado → llm_timeout / llm_aborted al modelo, no “Failed to fetch”.
  • Test: fake timers o server que no responde + expect abort y que no hubo segundo create.
  • No uses setTimeout alrededor del await sin abortar el socket.

El curso instalar un agente no pone estos techos. Streaming de respuestas cubre no pintar JSON a medias; aquí cubrís cuándo cortar. El hub de seguridad, coste y operación es el cluster.

FAQ

¿timeout: 0 o Infinity? No. El host igual te mata. Poné un número que quepa en maxDuration.

¿Axios timeout contra api.openai.com? Corta el HTTP. El SDK oficial ya tiene timeout + retries: usá uno, no los dos peleando.

¿Gemini? Misma regla: techo del cliente + stream en generaciones largas. No copies el 10 min de OpenAI “porque sí”.

¿El 10 min de Claude es el mismo que el del SDK de OpenAI? Casualidad de producto, no un estándar. Claude lo documenta como validación no-stream + 504. OpenAI lo documenta como default del cliente Node.

¿Puedo subir el timeout de Node fetch a 20 min con undici? Sí, client.ts muestra el Agent. Solo si elegiste 20 min (batch). En un bot, no.

Verificado 2026-09-06 contra openai-node README + src/client.ts, Claude API errors / streaming (platform.claude.com) y MDN AbortSignal.timeout.