cd ../back to blog
$Engineering//June 4, 2026//9 min read

Cómo se construye en serio un SLO del 99,95%

La ingeniería real tras el 99,95% de uptime mensual de Brievio: failover ponderado entre 12 upstreams, watchdog de primer byte de 50ms, ábaco de facturación y el trabajo operativo aburrido.

El SLO público de Brievio es de 99,95% de disponibilidad mensual en la API de chat. Suena a número de marketing, pero es real — limita nuestro presupuesto de errores a 21 minutos al mes, y lo hemos cumplido todos los meses desde el lanzamiento. Este artículo cuenta lo que de verdad lo sostiene: el failover de upstreams, el watchdog de primer byte, el ábaco de facturación y las cosas operativas aburridas que importan más que todo lo anterior.

La realidad: todos y cada uno de los upstreams tienen días malos

Por detrás hablamos con unos 12 upstreams. En cualquier mes dado vemos que cada uno de ellos sufre al menos una degradación parcial de entre 5 y 30 minutos. A veces es una caída de región (us-east-1 de Anthropic tuvo dos incidentes este trimestre). A veces es un estrangulamiento silencioso del token bucket (el limitador de tasa de Vertex AI es poco generoso los lunes). A veces es una caída total del proveedor (kie.ai se quedó a oscuras durante 47 minutos en marzo).

Si un único upstream se cae y no tienes failover, tu SLO es el que sea su SLO — menos tu sobrecarga de tránsito. Eso pone un techo duro en torno al 99,5%. Romper el 99,9% exige que ningún fallo de un solo upstream pueda tumbarte, y el 99,95% exige que dos fallos simultáneos tampoco puedan.

Capa 1: Enrutamiento ponderado de candidatos

Para cada modelo que alojamos hay de 1 a 4 rutas de upstream. El dispatcher las recorre en orden ponderado, con pesos que decaen en tiempo real según la tasa de éxito móvil (las últimas 100 llamadas por upstream y por región). Cuando us-east-1 empieza a devolver 5xx, su peso cae por debajo de us-west-2 en unos 5 segundos, y las nuevas peticiones dejan de ir ahí antes de que ningún cliente lo note.

dispatch.ts
// Pseudocódigo del dispatcher de upstreams. La implementación real
// (TypeScript, solo en servidor) tiene una forma muy parecida a esta.
async function dispatch(req: ChatRequest): Promise<ChatResponse> {
  const candidates = pickCandidates(req.model);
  // candidates = [{provider: "anthropic-direct", weight: 100},
  //               {provider: "google-vertex",   weight:  80},
  //               {provider: "kie-wholesale",   weight:  60}]
  // Menor peso = respaldo. Los pesos iniciales salen de las tasas de éxito móviles.

  const deadline = Date.now() + 540_000;          // límite duro de 540s
  let lastError: Error | null = null;

  for (const candidate of candidates) {
    const budgetMs = chunkBudget(deadline, candidates.length);
    try {
      return await Promise.race([
        callUpstream(candidate, req),
        timeout(budgetMs),
      ]);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // 4xx → sin failover
      recordFailure(candidate, err);              // el peso decae en tiempo real
      lastError = err;
    }
  }
  throw lastError ?? new Error("all upstreams exhausted");
}

Aquí hay dos cosas que importan y que es fácil hacer mal:

  • No hagas failover ante un 4xx. Un 400 del upstream significa que la petición estaba mal — reintentarla contra otro upstream producirá el mismo 400, solo que más lento. Solo los 408, 429, 5xx y los errores de red disparan el failover.
  • Acota el presupuesto por candidato. Si un deadline de 540s se reparte entre 3 candidatos, dale a cada uno unos 150s para empezar a hacer streaming o morir. No le des al primero los 540 enteros — si se cuelga, no te queda tiempo para el respaldo.

Capa 2: Watchdog de primer byte (50ms)

La mitad de los incidentes de upstream no son fallos duros — la conexión se abre, la petición se envía, y luego nada. Sin respuesta, sin error. Solo silencio. Un reintento ingenuo espera el timeout completo y luego prueba el siguiente, duplicando la latencia visible para el usuario.

Nuestro dispatcher arma un temporizador agresivo de primer byte de 50ms en cuanto la petición sale al cable. Si no vemos ni un solo byte de respuesta en esa ventana, abortamos y pasamos al siguiente. 50ms está por debajo del umbral de percepción, así que en el camino feliz el cliente no ve ningún retraso. En el camino de fallo, la petición del cliente hace failover al upstream de respaldo dentro de un único fotograma perceptible por un humano.

ttfb-watchdog.ts
// Detección de primer byte — falla RÁPIDO si el upstream se queda colgado.
async function callUpstream(c: Candidate, req: ChatRequest) {
  const ac = new AbortController();
  // Si no vemos ni un solo byte de respuesta en 50ms después de que la petición
  // sale al cable, tratamos el upstream como mudo y pasamos al siguiente.
  const firstByteWatchdog = setTimeout(() => ac.abort("ttfb-50ms"), 50);

  const res = await fetch(c.url, {
    method: "POST",
    body: JSON.stringify(req),
    signal: ac.signal,
  });

  clearTimeout(firstByteWatchdog);
  if (!res.body) throw new Error("no-body");

  // Ahora ya tenemos el primer byte. Devolvemos el stream; el cliente recibe
  // cada chunk según llega — sin búfer.
  return new Response(res.body, {
    headers: { "content-type": "text/event-stream" },
  });
}

Llegamos a los 50ms midiendo el P99 de TTFB de nuestros propios upstreams: incluso el upstream más lento produce su primer byte de respuesta por debajo de 40ms en el 99% de los casos. Cualquier cosa por encima de 50ms es señal, no ruido. Ajusta este número al P99 de tus propios upstreams.

Capa 3: El ábaco de facturación

La fiabilidad no va solo de uptime — va de si la facturación se mantiene correcta bajo carga. Al principio tuvimos una condición de carrera en la que dos peticiones simultáneas pasaban ambas el chequeo de saldo, luego ambas confirmaban, y la cuenta del usuario quedaba en negativo por $0,12. La solución fue una reserva en el momento de la escritura:

reserve-balance.ts
// El "ábaco de facturación" — reserva el coste estimado por adelantado para que
// dos peticiones grandes simultáneas no puedan pasar ambas el chequeo de saldo y luego sobregirar.
async function reserveBalance(userId: string, estCents: number) {
  return await db.transaction(async (tx) => {
    const bal = await tx.balance.findUnique({ where: { userId } });
    if (bal.balanceCents - bal.reservedCents < estCents) {
      throw new InsufficientQuotaError();
    }
    await tx.balance.update({
      where: { userId },
      data: { reservedCents: { increment: estCents } },
    });
    return { release: () => releaseReservation(userId, estCents) };
  });
}

Cada llamada estima su coste máximo por adelantado (tokens de input × tarifa de input + max_tokens × tarifa de output), reserva ese importe en la cartera en una única transacción y libera la parte no utilizada una vez que se conoce el uso real. Las peticiones simultáneas se serializan en la capa de transacciones. Sin sobregiros, nunca.

Lo aburrido, que importa más

Cualquier ingeniero que lea esto asiente ante los diagramas del dispatcher y piensa "genial, reintento ponderado, watchdog de primer byte, lo pillo". Pero lo que de verdad ha hecho que nuestro SLO aguante mes tras mes es menos emocionante:

  • Heartbeats en cada cron. Rotación de tokens, barridos de saldo, agregados de uso — cada uno de ellos hace ping a un endpoint de Healthchecks.io cuando termina. Un heartbeat perdido alerta en menos de 5 minutos. Hemos detectado más caídas por heartbeats perdidos que por alarmas explícitas.
  • Una página de estado que de verdad es precisa. brievio.com/status ejecuta comprobaciones sintéticas contra cada modelo cada 90 segundos. Cuando un upstream se degrada, se pone en amarillo antes de que los clientes lo noten.
  • Runbooks antes de que hagan falta los runbooks. Cada incidente que hemos tenido, incluidos los que detectamos antes de que afectaran a clientes, produjo una entrada de runbook. El busca de las 4 de la madrugada lleva una checklist; el ingeniero no tiene que pensar.
  • Sentry en todo, profiling en las rutas calientes. El dispatcher registra cada decisión de failover con el candidato, el motivo y el presupuesto restante. Si se cuela una regresión, la búsqueda es "muéstrame los failovers con reason=ttfb-50ms agrupados por upstream en esta hora".

Lo que no hacemos

Algunas cosas que hemos evitado a propósito, y que quizá esperarías de un gateway con 99,95%:

  • Base de datos multirregión activo-activo. Nuestra base de datos es de una sola región (iad). El retraso de replicación crearía inconsistencias de facturación que no queremos depurar. Si iad se cae del todo, pasamos a un modo de solo lectura — los clientes pueden seguir llamando a los modelos (el dispatcher es sin estado en el edge) pero no pueden ver el historial de uso durante una hora. Para nosotros ese equilibrio es el correcto.
  • Cambiar de modelo en silencio. Algunos agregadores, cuando no pueden alcanzar Claude Opus, enrutan a Haiku. Nosotros no — antes preferimos que recibas un 503 que un modelo distinto que no pagaste. El dispatcher solo hace failover al mismo modelo en un upstream diferente.
  • Reintentar de forma preventiva. Los 429 de Anthropic significan algo. Reintentar de inmediato empeora el problema para todos. Nuestro backoff es exponencial con jitter, limitado a 5 intentos y 30 segundos de espera total — la misma política que recomendamos en nuestra guía de errores.

A dónde va realmente el presupuesto

Nuestro presupuesto de errores para mayo de 2026 fue de 21 minutos 36 segundos. Usamos:

  • 3 minutos 12 segundos: un parpadeo de Anthropic en us-east-1 durante un deploy. El failover se disparó, no se cayó ninguna petición, pero la latencia P99 se disparó por encima del objetivo durante la ventana.
  • 1 minuto 50 segundos: una ráfaga de 5xx de kie.ai en un modelo de vídeo. Todos los reintentos tuvieron éxito, pero la latencia por llamada violó el SLO.
  • 0 de caída dura. Ninguna petición devolvió 5xx sin opción de reintento.

Eso son 5:02 de presupuesto consumido frente a un presupuesto de 21:36 — un 23% usado, holgadamente dentro del objetivo. El otro 77% es lo que nos permite desplegar de forma agresiva y absorber la siguiente sorpresa.

Qué significa esto para ti

Si estás construyendo sobre Brievio, no necesitas implementar tu propio failover de proveedores, tu propio watchdog de TTFB ni tu propio enrutamiento Anthropic-vs-Vertex. Ese es precisamente el objetivo: una sola base URL, un solo bearer token, el modelo genuino, y el SLO es nuestro deber defenderlo.

Si estás construyendo otro gateway y lees esto en busca de ideas: el dispatcher son 600 líneas de TypeScript. No es la parte complicada. Las partes complicadas son los runbooks de modos de fallo, los heartbeats, la página de estado y los meses de pequeñas cicatrices operativas que se acumulan hasta formar un sistema fiable. Planifícalas antes de lanzar.

Consulta las cifras de uptime actuales y el desglose por región en brievio.com/status, o la taxonomía completa de errores en nuestra documentación de errores.