Resiliencia: retries, fallbacks y load balancing

Por: Artiko
bifrostfallbacksload-balancingresilienciarouting

Resiliencia: retries, fallbacks y load balancing

En el capítulo 6 aprendimos a sacarle todo el jugo a la inferencia: streaming, tool calling, multimodal y reranking. Pero hasta ahora hemos asumido algo peligroso: que el proveedor siempre responde. En la vida real, OpenAI devuelve un 429 justo cuando tu producto está en portada de Hacker News, Anthropic tiene una incidencia de 20 minutos, una de tus claves agota su cuota a media tarde. Si tu aplicación habla directo con un único proveedor, cada uno de esos eventos es un incidente para tus usuarios.

La promesa de este capítulo es contundente: con la configuración correcta, tu aplicación percibe cero downtime aunque por debajo haya proveedores cayéndose, claves agotándose y cuotas saturándose. Bifrost convierte ese caos en algo invisible mediante cuatro mecanismos que se apilan: retries (reintentar el mismo proveedor), fallbacks (saltar a otro proveedor/modelo), load balancing (repartir carga entre varias claves) y routing (decidir a dónde va cada request según reglas). Vamos a montarlos uno a uno, entendiendo el porqué de cada decisión.

El mapa de la resiliencia

Antes de tocar configuración conviene tener clara la jerarquía. Cuando llega un request, Bifrost lo procesa en capas: primero decide a qué proveedor lo manda (routing y governance), luego elige qué clave usa dentro de ese proveedor (load balancing), intenta la llamada con su presupuesto de reintentos, y si el proveedor completo se agota, pasa al siguiente de la cadena de fallbacks.

flowchart TD
    A["Request entra al gateway"] --> B{"Routing rules / governance<br/>elige proveedor"}
    B --> C["Load balancing<br/>elige clave por peso"]
    C --> D["Intento con proveedor primario"]
    D --> E{"Resultado"}
    E -->|"Exito"| F["Respuesta al cliente"]
    E -->|"Error reintentable"| G{"Quedan retries?"}
    G -->|"Si"| H["Backoff exponencial<br/>+ jitter"]
    H --> C
    G -->|"No"| I{"Hay fallbacks?"}
    I -->|"Si"| J["Siguiente proveedor/modelo<br/>con su propio budget de retries"]
    J --> C
    I -->|"No"| K["Error final al cliente"]
    E -->|"Error de cliente 400/404/422"| K

Cada capa es independiente y configurable. Las veremos en este mismo orden: primero los retries (la defensa más cercana al proveedor), luego los fallbacks, después el load balancing entre claves y, por último, el routing que decide el punto de partida.

Retries: reintentar antes de rendirse

La mayoría de fallos de un proveedor LLM son transitorios: un pico de latencia, un 429 momentáneo, una conexión que se cae. Reintentar con una pequeña espera resuelve un porcentaje altísimo de estos casos sin que el usuario se entere. Bifrost implementa esto con una política de backoff exponencial con jitter, configurable por proveedor dentro de network_config.

Los tres campos de la política

La política de reintentos usa tres parámetros, todos dentro de network_config:

La fórmula del backoff

Bifrost calcula la espera de cada reintento así:

backoff = min(retry_backoff_initial × 2^attempt, retry_backoff_max) × jitter(0.8–1.2)

El componente 2^attempt es lo que hace que cada reintento espere aproximadamente el doble que el anterior (backoff exponencial). El min(..., retry_backoff_max) impone el techo. Y el jitter(0.8–1.2) añade una variación aleatoria del ±20% para evitar el thundering herd: si mil requests fallaran a la vez y todos reintentaran exactamente al mismo milisegundo, volverían a saturar al proveedor en sincronía. El jitter los desfasa.

Con los valores por defecto, la espera escala desde ~400–600 ms en el primer reintento hasta los 4–5 segundos a partir del quinto.

Cuándo se reintenta (y cuándo no)

Esto es clave para no malgastar reintentos en errores que nunca van a resolverse solos. Bifrost reintenta ante:

Bifrost NO reintenta ante:

El motivo es de sentido común: reintentar un 400 solo añade latencia y consume cuota sin ninguna posibilidad de éxito.

Rotación de claves durante los reintentos

Aquí Bifrost es inteligente. Cuando hay varias claves configuradas para un proveedor (lo veremos en la sección de load balancing), el comportamiento del reintento depende del tipo de error:

Configurar retries

En la Web UI (ver capítulo 3): ve a Providers, selecciona el proveedor, abre la sección Network Config y rellena Max Retries, Retry Backoff Initial y Retry Backoff Max.

Vía API con curl:

curl --location 'http://localhost:8080/api/providers' \
--header 'Content-Type: application/json' \
--data '{
    "provider": "openai",
    "keys": [{"name": "openai-key-1", "value": "env.OPENAI_API_KEY", "models": ["*"], "weight": 1.0}],
    "network_config": {
        "max_retries": 3,
        "retry_backoff_initial": 500,
        "retry_backoff_max": 5000
    }
}'

Y de forma declarativa en config.json:

{
  "providers": {
    "openai": {
      "keys": [
        { "name": "openai-key-1", "value": "env.OPENAI_KEY_1", "models": ["*"], "weight": 1.0 },
        { "name": "openai-key-2", "value": "env.OPENAI_KEY_2", "models": ["*"], "weight": 1.0 }
      ],
      "network_config": {
        "max_retries": 3,
        "retry_backoff_initial": 500,
        "retry_backoff_max": 5000
      }
    }
  }
}

Con esto, ante un 429 o un 503 Bifrost reintentará hasta 3 veces con backoff creciente antes de considerar al proveedor agotado. Solo entonces entra en juego el siguiente nivel de defensa.

Fallbacks: saltar a otro proveedor cuando el primario muere

Los reintentos resuelven fallos transitorios del mismo proveedor. Pero, ¿qué pasa si el proveedor entero está caído? Reintentar mil veces contra un OpenAI que tiene una incidencia global no sirve de nada. Para eso están los fallbacks: una lista ordenada de pares provider/model que Bifrost prueba en secuencia cuando el primario se agota.

Definir fallbacks por request

A diferencia de los retries (que se configuran en el proveedor), los fallbacks se pasan por request, en el campo fallbacks del body. Esto te da control granular: un endpoint crítico puede tener una cadena de tres fallbacks, mientras que uno secundario va sin red.

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "Explain quantum computing"}],
    "fallbacks": [
      "anthropic/claude-3-5-sonnet-20241022",
      "bedrock/anthropic.claude-3-sonnet-20240229-v1:0"
    ],
    "max_tokens": 1000,
    "temperature": 0.7
  }'

En este ejemplo, el primario es openai/gpt-4o-mini. Si OpenAI agota su presupuesto de reintentos, Bifrost prueba anthropic/claude-3-5-sonnet-20241022; si Anthropic también falla, cae a Claude vía bedrock. Fíjate en algo importante: los fallbacks pueden cruzar proveedores distintos y modelos distintos. No estás limitado a “el mismo modelo en otro proveedor”; puedes degradar a un modelo más barato o cambiar de familia por completo.

Cómo Bifrost identifica al ganador

La respuesta incluye metadata que te dice quién atendió finalmente el request, en extra_fields.provider, junto con extra_fields.latency para el tiempo de respuesta. Esto es oro para observabilidad (lo veremos en el capítulo 11): puedes detectar que tu primario está fallando con frecuencia porque cada vez más respuestas llegan etiquetadas con el proveedor de fallback.

El presupuesto de reintentos se multiplica

Hay un detalle de ejecución que conviene tener muy presente para no llevarse sorpresas con la latencia. Cada proveedor de la cadena recibe su propio presupuesto completo de reintentos. La documentación lo expresa de forma directa: un primario con max_retries: 3 y dos fallbacks, cada uno también con max_retries: 3, significa hasta 12 intentos totales antes de rendirse del todo.

sequenceDiagram
    participant App
    participant Bifrost
    participant OpenAI as openai/gpt-4o-mini
    participant Anthropic as anthropic/claude-3-5-sonnet
    participant Bedrock as bedrock/claude-3-sonnet

    App->>Bifrost: POST /v1/chat/completions (con fallbacks)
    Bifrost->>OpenAI: intento 1
    OpenAI-->>Bifrost: 503
    Bifrost->>OpenAI: reintentos 2-4 (backoff)
    OpenAI-->>Bifrost: 503 (budget agotado)
    Note over Bifrost: Primario agotado, salto a fallback 1
    Bifrost->>Anthropic: intento 1 (budget nuevo)
    Anthropic-->>Bifrost: 200 OK
    Bifrost-->>App: respuesta + extra_fields.provider = anthropic
    Note over Bedrock: nunca se usa: el fallback 1 tuvo exito

Esto es deliberado: cada proveedor merece una oportunidad justa antes de descartarlo. Pero implica que debes dimensionar max_retries con cabeza si la latencia te importa. Una cadena larga con muchos reintentos por eslabón puede tardar bastante en el peor caso.

Plugins y el control de la cadena

Cuando se disparan los fallbacks, los plugins (caching, governance, logging) se ejecutan de nuevo y desde cero para cada proveedor. Esto tiene sentido: el caché podría tener un hit para el modelo de fallback, o la governance podría aplicar límites distintos al segundo proveedor. Además, un plugin puede cortar la cadena en seco poniendo AllowFallbacks = false, deteniendo cualquier salto a fallbacks posteriores. Profundizaremos en plugins en el capítulo 12.

Trazabilidad

Todas las transiciones de retry y fallback se registran en el motor de routing core: rotaciones de clave (se loguea el nombre de la clave, nunca el secreto), evaluaciones de fallback y puntos de agotamiento. Lo hace sin exponer ni los mensajes del proveedor upstream ni las credenciales, lo cual es relevante para auditoría y cumplimiento.

Load balancing: múltiples claves por proveedor

Hasta aquí hemos hablado de saltar entre proveedores. Pero dentro de un mismo proveedor también necesitamos resiliencia y reparto de carga, y ahí entran las múltiples claves por proveedor. ¿Por qué querrías varias claves del mismo proveedor? Para repartir cuota (cada clave de OpenAI tiene su propio límite de RPM/TPM), para aislar entornos, y para tener failover automático si una clave se revoca o agota.

La estructura de una clave

Cada clave se configura como un objeto con estos campos:

Selección aleatoria ponderada

Bifrost usa weighted random selection para repartir requests entre las claves elegibles. El algoritmo es:

  1. Calcular el peso total de las claves elegibles.
  2. Generar un número aleatorio entre 0 y ese peso total.
  3. Seleccionar la clave según los rangos de peso acumulado.
  4. Hacer auto-failover a la siguiente clave disponible si la seleccionada falla.

El reparto es directamente proporcional a los pesos. Con dos claves de peso 0.7 y 0.3, la primera recibe ~70% de los requests y la segunda ~30%. Esta selección es extremadamente barata (del orden de nanosegundos), así que no introduce latencia perceptible en el camino crítico.

{
  "providers": {
    "openai": {
      "keys": [
        { "name": "openai-prod-a", "value": "env.OPENAI_KEY_A", "models": ["*"], "weight": 0.7 },
        { "name": "openai-prod-b", "value": "env.OPENAI_KEY_B", "models": ["*"], "weight": 0.3 }
      ],
      "network_config": {
        "max_retries": 3,
        "retry_backoff_initial": 500,
        "retry_backoff_max": 5000
      }
    }
  }
}

Filtrado de modelos por clave

El campo models no es solo decorativo: filtra qué claves son elegibles para cada request. La lógica es:

Esto te permite, por ejemplo, tener una clave de alto tier que solo sirve gpt-4o y otra de bajo coste para los modelos mini, y Bifrost elige automáticamente la adecuada según el modelo pedido.

Forzar una clave concreta

A veces no quieres balanceo, sino una clave específica (debugging, tests, aislamiento de un cliente). Puedes referenciarla y saltarte la selección ponderada:

Cuando se proporciona un ID o nombre explícito, la selección ponderada se omite y se usa directamente la clave referenciada.

Nota de versiones: el antiguo “Direct Key Bypass” (pasar tu propia clave de proveedor saltándote la gestión de Bifrost) fue eliminado por completo en v1.5, tanto del gateway HTTP como del Go SDK. Todos los requests deben usar claves de proveedor gestionadas por Bifrost.

Provider routing: ¿a qué proveedor va cada request?

Las dos secciones anteriores asumían que ya sabíamos a qué proveedor mandar el request. Pero esa decisión también la toma Bifrost, mediante un sistema de routing en capas que prioriza las reglas explícitas sobre la optimización automática.

El formato provider/model

Los requests usan el formato estandarizado provider/model, por ejemplo openai/gpt-4o, azure/gpt-4o o anthropic/claude-3-5-sonnet. Cuando especificas el proveedor en el prefijo, le estás diciendo a Bifrost exactamente a dónde ir.

Si en cambio mandas un nombre de modelo a secas (gpt-4o, sin prefijo), Bifrost resuelve el proveedor automáticamente consultando su Model Catalog, un registro interno de qué modelos sirve cada proveedor. Ambas formas son equivalentes cuando hay un único proveedor candidato:

# Nombre de modelo a secas: Bifrost resuelve el proveedor
{"model": "gpt-4o"}

# Proveedor explicito (resuelve al mismo resultado)
{"model": "openai/gpt-4o"}

Las capas de decisión

Cuando llega un request, Bifrost lo enruta atravesando estas capas, de mayor a menor prioridad:

  1. Routing rules (expresiones CEL, máxima prioridad): la veremos en la siguiente sección.
  2. Governance routing (configuraciones explícitas de Virtual Keys): cuando una VK define sus provider_configs, esa preferencia manda. La governance tiene precedencia porque el usuario la definió explícitamente. La cubrimos en el capítulo 9.
  3. Adaptive load balancing (basado en métricas de rendimiento): es una feature Enterprise. Puntúa proveedores y claves por tasas de error, latencia y utilización, y crea fallbacks ordenados por rendimiento.
  4. Model Catalog resolver (último recurso): resuelve el proveedor a partir del nombre de modelo cuando no hay nada más.

El adaptive load balancing descrito en el punto 3 (selección de proveedor y clave guiada por métricas en tiempo real, con exploración de claves en recuperación) es exclusivo de la edición Enterprise. El load balancing ponderado por pesos estáticos que vimos en la sección anterior sí está en la versión open source.

Routing ponderado vía governance

Dentro de la governance puedes definir varios proveedores para un mismo modelo, cada uno con un peso, y dejar que Bifrost reparta. Por ejemplo, mandando el 70% del tráfico a Azure y el 30% a OpenAI:

{
  "provider_configs": [
    {
      "provider": "openai",
      "allowed_models": ["gpt-4o", "gpt-4o-mini"],
      "weight": 0.3
    },
    {
      "provider": "azure",
      "allowed_models": ["gpt-4o"],
      "weight": 0.7
    }
  ]
}

Con esta configuración, ~70% de los requests resuelven a azure/gpt-4o y ~30% a openai/gpt-4o, y el proveedor no elegido se añade como fallback. Es la base de patrones de optimización de coste muy potentes (por ejemplo, mandar el 99% del tráfico a un proxy barato como OpenRouter y dejar el 1% en el proveedor directo como red de seguridad). El detalle completo de provider_configs, allowed_models, presupuestos y rate limits pertenece al capítulo 9.

Routing rules: enrutar según reglas dinámicas

El nivel más fino y potente del routing son las routing rules: expresiones CEL (Common Expression Language) que se evalúan en tiempo de request, antes de la selección de proveedor por governance, y que pueden sobreescribir esa selección por completo. Sirven para escenarios como “los usuarios premium van siempre al proveedor más rápido” o “si el presupuesto está al 85%, degrada a un modelo más barato”.

Anatomía de una regla

Cada routing rule contiene estos campos:

Variables CEL disponibles

Las expresiones tienen acceso a un contexto rico del request:

Y operadores y funciones de cadena habituales: ==, !=, >, <, &&, ||, !, .startsWith(), .endsWith(), .contains(), .matches(), y in para colecciones.

Ejemplo: ruta para clientes premium

{
  "governance": {
    "routing_rules": [
      {
        "id": "rule-uuid-123",
        "name": "Premium Tier Route",
        "description": "Route premium users to fast provider",
        "enabled": true,
        "chain_rule": false,
        "cel_expression": "headers[\"x-tier\"] == \"premium\"",
        "targets": [
          { "provider": "openai", "model": "gpt-4o", "weight": 0.7 },
          { "provider": "azure",  "model": "gpt-4o", "weight": 0.3 }
        ],
        "fallbacks": ["groq/gpt-3.5-turbo"],
        "scope": "global",
        "scope_id": null,
        "priority": 10
      }
    ]
  }
}

Esta regla dice: “si la cabecera x-tier vale premium, reparte 70/30 entre OpenAI y Azure (ambos gpt-4o), y si todo falla, cae a groq/gpt-3.5-turbo”. Fíjate en que la propia regla puede declarar sus fallbacks, integrándose con el mecanismo que vimos antes.

Orden de evaluación y chaining

Las reglas siguen first-match-wins dentro de una jerarquía de scopes, de mayor a menor prioridad: VirtualKey, luego Team, luego Customer y por último Global. Dentro de cada scope se ordenan por priority ascendente (el 0 antes que el 10).

Cuando chain_rule es true, el motor no se detiene tras el primer match: actualiza el contexto con el proveedor/modelo resuelto y re-evalúa todo el conjunto desde arriba. La cadena termina cuando ninguna regla hace match, cuando una regla con match tiene chain_rule: false, o cuando proveedor y modelo dejan de cambiar (detección de convergencia). El chaining de reglas está disponible a partir de Bifrost v1.5.0-prerelease2.

Independientemente de si el proveedor lo decidió una routing rule o la governance, el load balancing de selección de clave siempre se ejecuta después, sobre el proveedor ya determinado.

Performance tuning de proveedores

La resiliencia también depende de que cada proveedor tenga suficiente capacidad para absorber tu carga sin formar colas eternas. Bifrost expone tres palancas de rendimiento por proveedor (y una global) que conviene conocer a alto nivel; el tuning fino para producción lo detallamos en el capítulo 14.

Las palancas

La estructura por proveedor en config.json:

"concurrency_and_buffer_size": {
    "concurrency": 100,
    "buffer_size": 500
}

Y la configuración global:

"config": {
    "initial_pool_size": 10000,
    "drop_excess_requests": false
}

Dimensionar bien

La documentación ofrece una fórmula de partida basada en tus requests por segundo esperados (RPS): concurrency = expected_rps y buffer_size = 1.5 × expected_rps. Por ejemplo, a 500 RPS por proveedor, pondrías concurrency: 500 y buffer_size: 750.

Hay una restricción crítica que no puedes violar: buffer_size debe ser mayor o igual que concurrency. Si concurrency > buffer_size, el setup del proveedor falla al arrancar.

Qué pasa cuando la cola se llena

El campo global drop_excess_requests controla el comportamiento bajo saturación:

La elección entre ambos depende de tu SLA: para cargas con picos donde prefieres latencia controlada sobre throughput máximo, true evita que la cola crezca sin control.

Resumen

En este capítulo hemos apilado las cuatro capas que convierten un gateway frágil en una infraestructura que tu aplicación percibe como siempre disponible:

El mensaje de fondo: con estas capas bien configuradas, un proveedor caído, una clave revocada o un pico de tráfico dejan de ser incidentes visibles. Tu aplicación ve cero downtime percibido porque Bifrost absorbe el caos por debajo.

En el siguiente capítulo daremos un giro hacia el coste y la latencia: el semantic caching, que evita llamar al proveedor cuando una pregunta es semánticamente equivalente a otra ya respondida.

Siguiente: Semantic caching: reduce costo y latencia