wandres.dev
PATRÓN: MEDIA · reproductores y carga

Reconexión y degradación: sobrevivir a la red inestable

Reintentar una petición que falló y reconectar un recurso que se cayó se parecen lo justo para confundirse y difieren en lo esencial: la petición no tenía nada que restaurar y la conexión sí. Esta lección desarrolla la reconexión como estado con espera creciente y jitter, introduce la degradación como alternativa a la ruptura mediante una escalera de calidad recorrida por eventos, modela la red inestable como región ortogonal que informa sin mandar, y termina con la estrategia para probar de forma determinista un comportamiento que por naturaleza es errático.

⏱ 20 min

Un recurso de vida larga tiene un problema que una petición puntual no tiene: cuando se cae, no basta con volver a pedirlo. Había suscripciones activas, había una posición en el medio, había mensajes que se enviaron y quizá no llegaron, había una sesión que el servidor identificaba. Reintentar una petición fallida es repetir una función; reconectar un recurso es restaurar un contexto, y esa diferencia obliga a que el grafo tenga posiciones que el reintento simple no necesitaba. A eso se añade una asimetría todavía menos atendida: entre funcionar y fallar hay un territorio enorme de funcionar peor, y un sistema que solo sabe distinguir los extremos rompe en situaciones donde podría haber seguido sirviendo con menos resolución, menos frecuencia o menos garantías. Modelar la degradación como estados explícitos es lo que separa un cliente frágil de uno que la gente puede usar en un tren.

🎯 Al terminar esta lección sabrás
  • Distinguir reconexión de reintento y modelar la restauración del contexto perdido.
  • Implementar espera creciente con techo y jitter mediante retardos calculados desde el contexto.
  • Recorrer una escalera de calidad con eventos de degradación y recuperación en lugar de romper.
  • Modelar la red inestable como región ortogonal que informa sin gobernar a las demás.

Reconectar es un estado, y restaurar es otro

El grafo de reconexión tiene tres posiciones que el reintento simple no necesitaba. esperando_reconexion es la pausa entre intentos, y existe para que el usuario vea una cuenta atrás en lugar de un limbo. reconectando es el intento en curso. restaurando es la fase que casi nadie declara: tener el enlace abierto no significa haber recuperado el servicio, porque faltan las suscripciones, la posición y los mensajes pendientes de confirmación.

import { setup, assign, fromCallback } from 'xstate'

const TECHO = 30_000

export const enlace = setup({
  types: {
    context: {} as { intentos: number; pendientes: unknown[]; motivo: string | null },
    events: {} as
      | { type: 'ABIERTO' } | { type: 'CAIDO'; motivo: string } | { type: 'RESTAURADO' }
      | { type: 'RENDIRSE' } | { type: 'REINTENTAR_YA' },
  },
  actors: { socket: fromCallback(() => () => {}) },
  guards: { quedanIntentos: ({ context }) => context.intentos < 8 },
  delays: {
    ESPERA: ({ context }) => {
      const base = Math.min(TECHO, 2 ** context.intentos * 500)
      return Math.round(base / 2 + Math.random() * (base / 2))
    },
  },
}).createMachine({
  id: 'enlace',
  initial: 'conectando',
  context: { intentos: 0, pendientes: [], motivo: null },
  states: {
    conectando: {
      entry: assign({ intentos: ({ context }) => context.intentos + 1 }),
      invoke: { id: 'socket', src: 'socket' },
      on: {
        ABIERTO: 'restaurando',
        CAIDO: [
          { target: 'esperando_reconexion', guard: 'quedanIntentos' },
          { target: 'desconectado' },
        ],
      },
    },
    restaurando: {
      invoke: { id: 'socket', src: 'socket' },
      on: {
        RESTAURADO: { target: 'conectado', actions: assign({ intentos: 0, motivo: null }) },
        CAIDO: 'esperando_reconexion',
      },
    },
    conectado: {
      invoke: { id: 'socket', src: 'socket' },
      on: { CAIDO: { target: 'esperando_reconexion', actions: assign({ motivo: ({ event }) => event.motivo }) } },
    },
    esperando_reconexion: {
      after: { ESPERA: 'conectando' },
      on: { REINTENTAR_YA: 'conectando', RENDIRSE: 'desconectado' },
    },
    desconectado: {
      on: { REINTENTAR_YA: { target: 'conectando', actions: assign({ intentos: 0 }) } },
    },
  },
})
stateDiagram-v2
[*] --> conectando
conectando --> restaurando: ABIERTO
restaurando --> conectado: RESTAURADO
conectado --> esperando_reconexion: CAIDO
restaurando --> esperando_reconexion: CAIDO
conectando --> esperando_reconexion: fallo con intentos restantes
conectando --> desconectado: fallo sin intentos
esperando_reconexion --> conectando: vence la espera
esperando_reconexion --> desconectado: RENDIRSE
desconectado --> conectando: REINTENTAR_YA

El jitter de la línea del retardo no es un adorno estadístico. Sin él, todos los clientes que perdieron el enlace a la vez —porque el servidor cayó a la vez para todos— vuelven a llamar exactamente a la vez, y la reconexión se convierte en una segunda oleada tan sincronizada como la caída. Dispersar cada espera entre la mitad y el total de su ventana transforma la avalancha en una lluvia fina que el servidor sí puede absorber mientras se levanta.

💡
Reiniciar el contador en `restaurando`, no en `conectando`

Un detalle que decide si tu política funciona: el contador de intentos debe volver a cero cuando el servicio se ha restaurado de verdad, no cuando el enlace se abrió. Un servidor enfermo puede aceptar conexiones y cerrarlas medio segundo después, y si reinicias el contador al abrir, la espera creciente nunca crece y tu cliente martillea al servidor con la misma insistencia indefinidamente. Reiniciarlo en la entrada a conectado, tras la restauración completa, hace que el ciclo de aperturas fallidas cuente como lo que es: una serie de fracasos consecutivos que merece esperas cada vez más largas.

Degradar antes que romper

La segunda idea es que el fallo rara vez es binario. Entre servir a plena calidad y no servir hay una escalera, y recorrerla hacia abajo mantiene vivo el servicio en condiciones donde la alternativa era un error. La escalera se modela como estados hermanos ordenados, con eventos que bajan un peldaño ante señales de sufrimiento y suben uno ante señales de holgura sostenida.

calidad: {
  initial: 'alta',
  states: {
    alta: { on: { SUFRE: 'media' } },
    media: { on: { SUFRE: 'baja', HOLGURA: 'alta' } },
    baja: { on: { SUFRE: 'solo_audio', HOLGURA: 'media' } },
    solo_audio: { on: { HOLGURA: 'baja' } },
  },
}

Las dos asimetrías de este fragmento son deliberadas y ambas provienen de la experiencia. La primera es que se baja rápido y se sube despacio: la degradación responde a la primera señal de sufrimiento, mientras que la recuperación exige holgura mantenida durante una ventana, porque subir de peldaño al primer respiro produce oscilación visible y molesta. La segunda es que se baja de uno en uno en lugar de saltar al fondo, porque cada peldaño es una hipótesis sobre cuánto ancho de banda hay y conviene comprobarla antes de renunciar del todo.

Señal observada Reacción Motivo
búfer por debajo del umbral dos veces seguidas bajar un peldaño la tasa actual no se sostiene
fragmento descargado más lento que su duración bajar un peldaño vamos perdiendo terreno contra el reloj
búfer holgado durante una ventana larga subir un peldaño hay margen probado, no un respiro puntual
enlace caído conservar el peldaño la calidad no era el problema | no bajar por ruido
batería baja o pestaña oculta bajar deliberadamente ahorro de recursos, no falta de red
⚠️
Degradar no es fallar, y confundirlos arruina la telemetría

El estado degradado debe informarse como una situación de servicio, no como un error. Si tu telemetría cuenta cada bajada de peldaño como incidente, el panel se llena de ruido y el equipo deja de mirarlo; si no la cuenta en absoluto, nadie descubre que el treinta por ciento de las sesiones vive permanentemente en el peldaño más bajo. La medida correcta no es cuántas veces se degradó sino cuánto tiempo se pasó en cada peldaño, porque esa distribución es la que revela si el problema es tu codificación, tu red de distribución o el dispositivo del usuario.

La red como región que informa sin mandar

La tentación al llegar aquí es hacer que la región de red controle directamente a las demás, y es un error de acoplamiento. La red no debe decidir la calidad ni el transporte: debe publicar hechos que las otras regiones interpretan según sus propias reglas. Así la política de cada dimensión se puede cambiar por separado, y la región de red se puede probar sin montar nada más.

📶

Región de red

Publica estable, inestable y sin_red. No conoce la existencia del transporte ni de la calidad.

🪜

Región de calidad

Recorre la escalera reaccionando a las mediciones del búfer, no a la etiqueta de la red.

🎬

Región de transporte

Sigue mandando el reproductor. Sin red pasa a buffering, que ya era su lenguaje para esperar datos.

🧊

Sin comunicación encubierta

Ninguna región lee la posición de otra. Si hace falta influir, se envía un evento explícito y queda rastro.

Lo inestable se prueba de forma determinista o no se prueba. La receta consiste en tres piezas: un reloj simulado que permita adelantar el tiempo sin esperarlo de verdad, una fuente de aleatoriedad inyectada para que el jitter sea reproducible fijando la semilla, y un doble del recurso que emita fallos y recuperaciones bajo tu control. Con esas tres piezas puedes afirmar propiedades exactas: que la quinta espera está entre dos valores concretos, que el contador se reinicia solo tras la restauración completa, que salir de la espera cancela el temporizador y nada se dispara después.

const espera = (intentos: number, azar: number) => {
  const base = Math.min(30_000, 2 ** intentos * 500)
  return Math.round(base / 2 + azar * (base / 2))
}
// espera(4, 0) === 4000 y espera(4, 1) === 8000: la ventana es verificable
La resiliencia es una política declarada, no una reacción improvisada

Todo lo que esta lección construye descansa sobre una sola inversión conceptual: dejar de tratar el fallo como una interrupción del programa y empezar a tratarlo como una entrada más del modelo. Un bucle de reconexión escrito dentro de una función asíncrona no es solo difícil de probar, es epistemológicamente opaco: mientras espera no puede informar, no puede obedecer, no puede rendirse antes de tiempo y no puede decir dónde está. Ese hueco en el que no ocurre nada visible es donde se esconden todos los defectos de los clientes frágiles. Convertir la espera en un estado con nombre transforma un vacío en una situación, y una situación sí admite interfaz, cancelación, medición y prueba. La escalera de calidad añade la segunda mitad de la idea y quizá la más importante: la resiliencia no consiste en resistir sino en tener preparada de antemano una respuesta gradual al deterioro. Un sistema que solo conoce dos posiciones, funcionar y fallar, delega en el azar la decisión de cuál de las dos le toca; un sistema que declara peldaños intermedios ha decidido por adelantado qué está dispuesto a sacrificar y en qué orden, y esa decisión es una afirmación sobre el valor relativo de cada parte del servicio. Renunciar al vídeo antes que al audio no es una optimización técnica, es una tesis sobre para qué sirve el producto. Por eso el grafo de degradación acaba siendo el documento más honesto que existe sobre las prioridades reales de un sistema: cualquiera puede leer, sin ambigüedad y sin entrar en el código, qué se abandona primero cuando las cosas van mal. Un equipo que no puede escribir esa escalera todavía no ha decidido qué está construyendo, solo ha decidido qué quiere que ocurra cuando todo va bien.

⚔️ Haz que tu cliente sobreviva al túnel
  1. Implementa el grafo de reconexión y comprueba que la espera crece hasta el techo y se detiene ahí en lugar de seguir doblando.
  2. Reinicia el contador al abrir el enlace en vez de al restaurar, simula un servidor que acepta y cierra de inmediato, y describe el martilleo resultante.
  3. Añade jitter con una fuente de azar inyectable y verifica en un test que la quinta espera cae dentro de la ventana esperada.
  4. Construye la escalera de calidad y comprueba que bajar es inmediato mientras subir exige holgura sostenida durante una ventana completa.
  5. Convierte la red en región ortogonal, prohíbete que ninguna otra región lea su posición y comunica con eventos explícitos donde haga falta.
  6. Corta la red del dispositivo a mitad de reproducción, restáurala treinta segundos después y verifica que el transporte vuelve solo sin intervención del usuario.