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.

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
| Reloj | Quién lo pone | Qué corta | Qué no corta |
|---|---|---|---|
| Tool HTTP | AbortSignal.timeout en el fetch de la tool | El GET/POST de esa tool | La llamada al modelo |
| LLM SDK | timeout del cliente / por request | Headers + body de esta generación | Las tools ya disparadas |
| Host | maxDuration, Workers CPU, docker stop | El proceso entero | Nada 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_error — The 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.

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:
| Superficie | Techo | Por qué |
|---|---|---|
| Chat / ack 3 s (Discord) | 2–8 s al primer token; stream el resto | El ack no es la generación |
| Tool-calling corto | 20–45 s | Un JSON de tool no necesita 10 min |
| Reasoning / thinking | el del producto, explícito | Los tokens internos cuentan como output |
| Batch / offline | stream o Batches API | Claude 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.

Qué hacer con el error
| Error | Acción | No hagas |
|---|---|---|
APIConnectionTimeoutError / 504 timeout_error | 1 retry con jitter o fallback de modelo | 3 retries del SDK × 10 min |
User abort (AbortError / APIUserAbortError) | Cortar el turno; no retry | Tratarlo como 503 |
| Idle drop mid-stream | Claude documenta resume del stream en 4.5 y anteriores | Rehacer el prompt entero a ciegas |
| 401 / 403 / spend | Fallar cerrado | Timeout no arregla auth |
| Timeout + tool ya ejecutada | Idempotencia; 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
timeoutexplícito, no 600_000 ms. maxRetries: 0en la llamada; retry lo pone tu política.signalpor turno, combinado con stop del usuario.stream: truesimax_tokenso thinking pueden pasar de ~1 min.- Primer token tiene techo más corto que el techo total.
- Catch tipado →
llm_timeout/llm_abortedal modelo, no “Failed to fetch”. - Test: fake timers o server que no responde + expect abort y que no hubo segundo
create. - No uses
setTimeoutalrededor 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.
Lecturas relacionadas
Sigue explorando AgentOps y otras piezas para builders.

CoT vs thinking models: no pidas ‘piensa paso a paso’ al modelo que ya piensa

Idempotencia en tools de agentes: una clave, un efecto

Temperatura y sampling en agentes: el knob no es creatividad
