Volver al blog
Ingeniería·24 de mayo de 2026·9 min de lectura

Cómo funciona el failover de Kunavo: las cuatro capas entre un error del upstream y tu cliente

Toda la ruta de failover, con la constante detrás de cada número: un disyuntor que omite un canal con fallos antes de enviar la solicitud, hasta tres saltos ordenados por costo dentro de una misma solicitud, una compuerta de streaming que se niega a confirmar un 200 que se convierte en error y un respaldo de cuatro minutos para un upstream que nunca responde.

Cada publicación sobre gateways que lees cita un porcentaje de disponibilidad. Esta no lo hace porque no publicamos un SLA y preferimos describir un mecanismo que puedas comprobar en lugar de una cifra que no puedas verificar. Lo siguiente es todo el recorrido entre el momento en que un upstream devuelve un error y el momento en que tu cliente recibe uno: cuatro capas, cuatro constantes y las razones por las que cada constante tiene ese valor.

Por qué un único upstream no basta

Que un proveedor de modelos tenga veinte minutos malos es algo habitual. Incidentes regionales, un endurecimiento del limitador de velocidad o una falta de capacidad en un modelo recién lanzado: nada de esto es exótico y todo te resulta invisible hasta que aumenta tu propia tasa de errores. Si tu aplicación se comunica con un solo endpoint, tu disponibilidad es la disponibilidad de ese endpoint y, desde el cliente, no puedes hacer nada salvo reintentar; para entonces el usuario ya ha esperado.

La alternativa es tener otro destino al que acudir. Kunavo resuelve una cadena de enrutamiento para cada modelo: hasta tres canales independientes que pueden servirlo, ordenados por coste. Las cuatro capas siguientes explican cómo una solicitud recorre esa cadena sin que tengas que escribir lógica de reintento.

Capa 1: el disyuntor (antes de enviar la solicitud)

El failover más barato es el que nunca cuesta un viaje de ida y vuelta. Cada proceso del gateway mantiene un recuento móvil de fallos de cinco minutos por canal. Con tres fallos dentro de esa ventana, el canal se considera abierto: durante los siguientes sesenta segundos se elimina de la cadena antes de despachar ninguna solicitud.

provider-health.ts
// Layer 1, simplified from lib/providers/provider-health.ts.
// A channel that has been failing is skipped BEFORE we send anything,
// so the cost of avoiding it is zero — no request, no timeout, no wait.
const WINDOW_MS   = 300_000;  // failures older than 5 minutes are forgotten
const FAIL_THRESHOLD = 3;     // 3 failures inside that window opens the circuit
const COOLDOWN_MS = 60_000;   // and the channel is bypassed for 60 seconds

function isOpen(channel: string, now = Date.now()): boolean {
  const s = state.get(channel);
  return s != null && s.openUntil > now;
}

function recordFailure(channel: string, now = Date.now()) {
  const s = state.get(channel) ?? { failures: [], openUntil: 0 };
  s.failures = s.failures.filter((t) => now - t < WINDOW_MS);
  s.failures.push(now);
  if (s.failures.length >= FAIL_THRESHOLD) s.openUntil = now + COOLDOWN_MS;
}

Esta es la única capa verdaderamente instantánea, y conviene precisar por qué: no se está midiendo nada con rapidez. La decisión la tomó hace como máximo cinco minutos una solicitud anterior que asumió el fallo en tu nombre. El estado es por proceso, de modo que cada máquina aprende de forma independiente; con tráfico real, un canal defectuoso se activa en todas ellas con una diferencia de pocos segundos, y un canal que solo estuvo indisponible brevemente se recupera por sí solo cuando termina el periodo de enfriamiento.

Capa 2: failover dentro de la solicitud (hasta tres saltos)

Cuando un canal que parecía saludable devuelve un error, la solicitud pasa al siguiente de la cadena. El límite es de tres saltos, ROUTING_CHAIN_MAX, y la cadena está ordenada por coste, por lo que el primer intento siempre usa el canal más barato que puede servir el modelo.

dispatch.ts
// Layer 2. The routing chain is resolved from the catalog, cost-ordered,
// and capped at ROUTING_CHAIN_MAX hops (3). It is static configuration, not
// a learned weighting: you can read the exact chain for any model in the
// admin UI, and it is the same chain for every customer.
async function dispatch(req: ChatRequest) {
  const chain = routingChain(req.model)          // up to 3 channels, cheapest first
    .filter((c) => !isOpen(c.id));               // layer 1 removes the known-bad

  let lastError: UpstreamError | null = null;
  for (const channel of chain) {
    try {
      return await callUpstream(channel, req);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // a 400 is yours, not ours
      recordFailure(channel.id, Date.now());
      lastError = err;
    }
  }
  throw lastError ?? new Error("all channels exhausted");
}

Los errores del cliente no activan el failover. Un 400 por una solicitud malformada o un 404 por un modelo inexistente tiene la misma respuesta en todos los canales, así que reintentarlo tres veces solo te haría esperar tres veces más el mismo mensaje. Solo los fallos reintentables del upstream recorren la cadena.

El coste real de esta capa es un viaje de ida y vuelta fallido por salto. Si un canal acepta la conexión y luego devuelve 503 después de dos segundos, esperaste esos dos segundos antes de iniciar el segundo intento. Esa es la verdadera razón por la que importa la capa 1: evita que un canal que falla de forma constante cobre ese peaje de dos segundos en cada solicitud.

Capa 3: la compuerta del streaming (30 segundos)

El streaming dificulta el failover de una forma fácil de pasar por alto. Un upstream puede devolver 200 OK, abrir un stream y solo después emitir un evento de error. Cuando llega, un proxy ingenuo ya ha enviado a tu cliente una cabecera y algunos bytes, y ya no hay forma de pasar al siguiente canal: la solicitud está comprometida con un canal que no va a responderla.

stream-gate.ts
// Layer 3, from lib/providers/stream-gate.ts. An upstream can answer 200 OK
// and then put the error inside the stream. Committing that to your client
// means you get a truncated answer and we cannot fail over any more, so the
// gateway holds the first frames back until it knows which it is.
export const STREAM_GATE_TIMEOUT_MS = 30_000;

// Outcomes:
//   a decisive content frame  -> commit, stream the rest through untouched
//   a decisive error frame    -> discard, fall through to the next channel
//   nothing decisive in 30s   -> commit anyway (a slow model is not an error)

Por eso el gateway no confirma la solicitud basándose en el código de estado. Retiene las tramas iniciales hasta ver algo concluyente: contenido real, en cuyo caso confirma el stream y lo transmite sin modificaciones a partir de ahí; o un error, en cuyo caso no te ha llegado nada y la solicitud continúa por la cadena. Treinta segundos es el límite para tomar esa decisión, y existe para modelos de razonamiento que legítimamente tardan mucho en emitir el primer token. Después confirmamos la solicitud, porque un modelo lento no es un modelo averiado.

Capa 4: tiempo de espera de cabeceras (240 segundos)

La última capa es la que nadie disfruta. Si un upstream acepta la conexión y después no devuelve ninguna cabecera de respuesta, la solicitud queda bloqueada y lo único que puede terminarla es un tiempo de espera: UPSTREAM_HEADERS_TIMEOUT_MS, cuatro minutos.

Cuatro minutos es mucho tiempo para esperar, y es deliberado. La generación de vídeo e imágenes puede tardar legítimamente varios minutos; un tiempo de espera suficientemente corto para que el chat parezca ágil cancelaría trabajos de vídeo que iban a completarse. Además, un upstream bloqueado es poco frecuente comparado con uno que falla de forma explícita, y los casos explícitos ya los gestionan las capas 1 a 3. Preferimos ser lentos en el caso raro antes que equivocarnos en el habitual.

Si quieres un límite más estricto para tu propia aplicación, configura un tiempo de espera en el cliente. El nuestro es una red de seguridad, no un objetivo de latencia.

Lo que no afirmamos

  • No ofrecemos una cifra de failover en milisegundos. La capa 1 no cuesta ningún viaje de ida y vuelta porque la decisión es anterior a tu solicitud. Las capas 2 a 4 duran exactamente lo que tardó el upstream fallido en fallar. Ninguna de las dos cosas es una cifra que podamos mostrar honestamente en una página de marketing.
  • No ofrecemos un porcentaje de disponibilidad. No hay SLA ni sistema de créditos, así que una cifra sería meramente decorativa. La página de estado muestra la tasa de éxito que observamos realmente, incluidas las ventanas en las que no fue buena.
  • No sustituimos modelos silenciosamente. El failover mueve una solicitud entre canales que sirven el mismo modelo. Nunca respondemos con un modelo más barato que el que solicitaste; es una práctica que conviene preguntar directamente a cualquier gateway porque resulta invisible en la respuesta.

Qué significa esto cuando estás construyendo sobre ello

Principalmente, significa que puedes eliminar un bucle de reintento. Los errores reintentables del upstream ya se reintentan entre canales independientes antes de que veas un fallo, así que un reintento del cliente ante un 5xx nuestro vuelve a intentar algo que ya se ha probado tres veces. Conserva tu tiempo de espera y elimina el backoff.

Lo único que merece la pena gestionar por tu cuenta es un 402 insufficient balance, que no es un caso de failover: ningún número de reintentos rellena una cartera. Consulta la referencia de errores para saber qué estados merecen un segundo intento y cuáles son definitivos.