Guía10 min

Circuit breaker y kill switch en agentes: cortar la dependencia, no el proceso

Resumen

Un circuit breaker corta UNA dependencia (modelo, Postgres, MCP) cuando el fallo deja de ser transitorio: Closed, Open y Half-Open. Distinto de reintentos (por llamada) y del kill switch (apagar el agente entero). Azure Architecture + opossum 10.0.0. Cero retry contra Open. Fail-fast, no setTimeout decorativo.

OpenAIGitHub
Interruptor de tres estados Closed Open Half-Open frente a un agente y una dependencia remota

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 no se cae porque el modelo “se equivoque de tono”. Se cae porque una llamada remota se queda colgada, el webhook ya contestó 200 y el proceso reintenta contra un 503 persistente. El Retry pattern asume fallo transitorio. El Circuit Breaker (Azure Architecture Center, 2026-09-06) asume lo contrario: temporarily block access to a remote service after failures reach a threshold, instead of repeatedly retrying an operation that's likely to fail.

No es reintentos. Eso es por llamada. Tampoco es el kill switch: eso apaga el agente entero (deleteWebhook, AGENT_DISABLED, fly scale 0). El breaker es por dependencia. El kill switch es por sistema.

Contrato: un breaker por dependencia crítica. Closed deja pasar. Open falla inmediato. Half-Open prueba con poquitas. Cero retry contra Open. Cero breaker global que apague el bot. El kill switch lo mueve un humano, no el umbral.

La regla de oro: tres estados, una dependencia

Azure describe el proxy como máquina de estados que imita un interruptor eléctrico:

EstadoQué haceCuándo sale
ClosedEnruta la llamada. Cuenta fallos recientes en una ventana de tiempoUmbral de fallos → Open
OpenFalla inmediato. No llama al proveedor. Arranca un timerTimer expira → Half-Open
Half-OpenDeja pasar un número limitado de pruebasÉxitos consecutivos → Closed. Un fallo → Open otra vez

El contador de Closed es temporal: se resetea solo. Un 429 aislado no abre. El umbral dispara Open solo si N fallos ocurren en un intervalo. Half-Open existe para no inundar un servicio que está recuperándose.

Fowler (2014): envuelves la llamada, cuentas fallos, y al cruzar el umbral las siguientes devuelven error sin tocar al proveedor. Azure: Retry y Circuit Breaker se combinan, pero the retry logic should be sensitive to any exceptions that the circuit breaker returns and stop retry attempts if the circuit breaker indicates that a fault isn't transient.

Tres estados Closed, Open y Half-Open de un circuit breaker sobre una dependencia remota

Qué cuenta como fallo (y qué no)

No todos los errores tripan el breaker. Fowler: Not all errors should trip the circuit, some should reflect normal failures. Azure pide mirar el tipo de excepción y ajustar el umbral. En un agente:

Señal¿Cuenta para el umbral?Por qué
Timeout / AbortSignalEl proveedor no contestó a tiempo
429 / 500 / 503 / 529Transitorio hasta que deja de serlo
Conexión rehusada / DNSLa dependencia no está
400 schema / 401 / 403NoArreglas el request o rotas la clave; no esperas
404 de un id inventadoNoEs lógica de negocio
Fallback del propio breakerEn opossum, El README 10.0.0: When a fallback function is triggered, it's considered a failure

El timeout de la llamada vive en AbortSignal, no en un setTimeout decorativo. El breaker usa ese timeout como fallo. No lo sustituye.

opossum 10.0.0: el contrato en Node

opossum (Apache-2.0, Node >= 22, latest 10.0.0 el 2026-09-06, docs en nodeshift.dev/opossum) ejecuta una función async y plays dead and fails fast. Defaults útiles para un tool de agente:

import CircuitBreaker from "opossum";

const callModel = (payload, signal) =>
  fetch("https://api.openai.com/v1/responses", {
    method: "POST",
    body: JSON.stringify(payload),
    signal,
  }).then((r) => {
    if (!r.ok) throw Object.assign(new Error(String(r.status)), { status: r.status });
    return r.json();
  });

const breaker = new CircuitBreaker(callModel, {
  timeout: 8000,
  errorThresholdPercentage: 50,
  resetTimeout: 30_000,
  volumeThreshold: 10,
  errorFilter: (err) => err.status === 400 || err.status === 401 || err.status === 403,
});

breaker.fallback(() => ({ degraded: true, reason: "model_open" }));
breaker.on("open", () => metrics.inc("breaker_open", { dep: "openai" }));
breaker.on("halfOpen", () => metrics.inc("breaker_half_open", { dep: "openai" }));
breaker.on("close", () => metrics.inc("breaker_close", { dep: "openai" }));

timeout: 3000 en el README es el ejemplo; 8 s es más realista para un Responses call. errorThresholdPercentage: 50 es el 50 % de Fowler. resetTimeout: 30000 es Open → Half-Open. volumeThreshold: 10 evita abrir el circuito con tres llamadas de calentamiento (default 0). errorFilter truthy no incrementa fallos: 400/401/403 no son “el modelo está caído”.

Fallback no es éxito. opossum lo cuenta como fallo y lo sigue ejecutando hasta Closed. Devuelve JSON degradado (degraded: true), nunca un tool result que parezca éxito.

Serverless: breaker.toJSON() serializa state y status. Sin KV/Redis cada cold start arranca Closed. Un breaker en memoria de un Worker Free no es kill switch.

Un breaker por dependencia, no uno para el bot

Azure pide resource differentiation: no mezcles shards ni proveedores independientes en un solo umbral. En un agente:

  1. Modelo (OpenAI / Anthropic / Gemini): 50 % errores, resetTimeout 30–60 s.
  2. Base de datos / cola: umbral más bajo (3–5 timeouts). Un Postgres saturado no se recupera a palos de retry.
  3. MCP / tool HTTP: breaker propio. Un servidor MCP colgado no debe abrir el del modelo.
  4. Canal (Telegram getUpdates / Slack): casi nunca. Si el canal muere, eso es kill switch, no Half-Open.

Half-Open limita las pruebas. 40 fire() en paralelo al reabrir tumban al proveedor. Una prueba basta.

El override de Azure es el puente al kill switch: an administrator can force a circuit breaker into the Open state. En prod: BREAKER_FORCE_OPEN=openai, distinto de AGENT_DISABLED.

Kill switch: otra palanca, otro dueño

PalancaQuién la mueveQué cortaQué no hace
Retry + jitterEl runtime, por llamadaNada; espera y reintentaNo protege si el fallo es persistente
Circuit breakerEl umbral, por dependenciaLlamadas a esa APINo borra el webhook ni las réplicas
Kill switchUn humano (o runbook)Canal + flag + réplicasNo diagnostica qué dependencia falló

Si el agente alucina en un grupo, no esperes al breaker: deleteWebhook + drop_pending_updates. Si OpenAI lleva 20 min en 503, no apagues Telegram: abre el breaker del modelo, sirve fallback y alerta.

Observabilidad: evento en cada cambio de estado hacia logs. El healthcheck de liveness no falla porque una tool esté Open: el proceso vive; la dependencia no.

Capas: retry por llamada, breaker por dependencia y kill switch del agente entero

Checklist para el PR del agente

  1. Un CircuitBreaker nombrado por dependencia (openai, postgres, mcp_github). Cero singleton global.
  2. errorFilter para 400/401/403. El schema roto no abre el circuito.
  3. Timeout de la llamada = AbortSignal. El timeout de opossum aborta; no dejes el fetch huérfano.
  4. Fallback devuelve degradado explícito. Nunca un tool result que parezca éxito.
  5. Listeners open / halfOpen / close con label de dependencia. Alerta en Open, no en cada 429.
  6. Half-Open: una prueba, no un Promise.all de tools.
  7. Flag de override FORCE_OPEN / FORCE_CLOSED por dependencia, gated. Distinto de AGENT_DISABLED.
  8. Ensayo: tumba el mock del modelo, verifica fail-fast < 50 ms, verifica que Telegram sigue contestando “degradado”.
  9. En serverless, documenta que el estado no viaja. Si importa, KV. Si no, cold start = Closed.
  10. Cero retry después de CircuitBreakerOpen. Azure: el retry se detiene cuando el breaker dice que el fallo no es transitorio.

FAQ

¿Puedo usar el mismo breaker para el modelo y para Postgres? No. Umbrales distintos, recuperaciones distintas. Un 50 % de 429 de OpenAI no debe cortar los checkpoints.

¿El fallback cierra el circuito? No. En opossum 10.0.0 el fallback cuenta como fallo. Closed solo vuelve con éxitos reales en Half-Open.

¿Open es lo mismo que kill switch? No. Open corta una dependencia. El kill switch corta el canal y las réplicas. Un humano lo arma; un umbral no desinstala el bot.

¿Reintento dentro del breaker? Sí, antes de contar el fallo, y solo códigos transitorios. Si el breaker ya está Open, cero retry. Combina los dos patterns; no los fusiones en un while (true).

¿Dónde encaja el curso? En instalar un agente el runtime sale hablando. Esta guía es lo que pones antes de que un 503 convierta el webhook en un fork bomb de retries. Hub: seguridad, coste y operación.

Fuentes 2026-09-06: Azure Circuit Breaker + Retry, Fowler 2014, npm [email protected], nodeshift.dev/opossum.