Reintentos y backoff: el intento como estado
Un bucle de reintentos escrito dentro de una función asíncrona es invisible: no se puede inspeccionar, no se puede cancelar limpiamente y no se puede contar desde fuera. Modelarlo como estados lo vuelve material: el intento pasa a ser una posición del grafo, la espera entre intentos pasa a ser una transición temporizada con delays parametrizados por el contexto, y el agotamiento pasa a ser un destino con nombre. La lección desarrolla el backoff exponencial con jitter, la taxonomía de fallos que merecen reintento y la frontera con el reintento que la cache ya hace por ti.
El reintento es la operación asíncrona que peor envejece cuando se escribe a mano. Empieza siendo un while con un contador, gana un await de espera, luego un techo para no esperar diez minutos, luego una condición para no reintentar los errores que no lo merecen, y termina siendo veinte líneas dentro de una función que nadie puede observar, cancelar ni probar por partes. Modelar los intentos como estados invierte esa trayectoria: el contador deja de ser una variable escondida y pasa a ser contexto visible, la espera deja de ser un await opaco y pasa a ser una transición temporizada que se cancela sola al salir del estado, y la decisión de rendirse deja de estar enterrada en una condición para ser un estado con nombre al que la UI puede reaccionar.
- Convertir un bucle de reintentos en estados explícitos con contador en el contexto.
- Parametrizar el retardo con
delayscalculados desde el contexto para lograr backoff exponencial. - Clasificar los fallos entre los que merecen reintento y los que solo lo desperdician.
- Situar la frontera con el reintento que la cache de datos ya realiza por su cuenta.
El intento como estado, no como bucle
La transformación es sencilla de enunciar: donde había una iteración, pon dos estados —uno que ejecuta y otro que espera— y una arista que los une. El contador de intentos vive en el contexto, la guarda decide si queda margen y el agotamiento tiene su propio destino.
import { setup, assign, fromPromise } from 'xstate'
const MAXIMO = 4
export const lectura = setup({
types: {
context: {} as { intentos: number; error: unknown },
events: {} as { type: 'CARGAR' } | { type: 'CANCELAR' } | { type: 'REINTENTAR' },
},
actors: {
leer: fromPromise(async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch('/api/informe', { signal })
if (!res.ok) throw Object.assign(new Error('http'), { status: res.status })
return res.json()
}),
},
guards: {
quedanIntentos: ({ context }) => context.intentos < MAXIMO,
},
delays: {
ESPERA: ({ context }) => Math.min(20_000, 2 ** context.intentos * 400),
},
}).createMachine({
initial: 'inactivo',
context: { intentos: 0, error: null },
states: {
inactivo: { on: { CARGAR: 'cargando' } },
cargando: {
entry: assign({ intentos: ({ context }) => context.intentos + 1 }),
invoke: {
src: 'leer',
onDone: { target: 'exito', actions: assign({ intentos: 0 }) },
onError: [
{ target: 'esperando', guard: 'quedanIntentos', actions: assign({ error: ({ event }) => event.error }) },
{ target: 'agotado', actions: assign({ error: ({ event }) => event.error }) },
],
},
},
esperando: {
after: { ESPERA: 'cargando' },
on: { CANCELAR: 'inactivo' },
},
exito: { on: { CARGAR: 'cargando' } },
agotado: { on: { REINTENTAR: { target: 'cargando', actions: assign({ intentos: 0 }) } } },
},
})
Tres propiedades aparecen gratis con esta forma y ninguna existía en el bucle. La primera es la observabilidad: esperando es un estado real, así que la interfaz puede decir reintentando en cuatro segundos en lugar de fingir que sigue cargando. La segunda es la cancelabilidad: el evento CANCELAR sale de esperando y el temporizador se descarta automáticamente, sin banderas ni identificadores de setTimeout que limpiar. La tercera es la distinción entre fallo transitorio y fallo definitivo: agotado no es lo mismo que un error cualquiera, y darle estado propio permite ofrecer ahí el botón de reintento manual que reinicia el contador.
Fíjate en el reparto interno: el contexto guarda un hecho —cuántos intentos llevo— y delays guarda una política —cuánto se espera dado ese hecho—. Separarlos permite cambiar la curva de espera sin tocar el grafo, probar la política como función pura y, si algún día la política viene de configuración remota, sustituirla con provide sin reescribir un solo estado.
El retardo como transición temporizada
La espera entre intentos merece atención propia porque es donde vive el diseño real. Una espera fija reintenta demasiado pronto cuando el servidor está congestionado y demasiado tarde cuando el fallo fue un parpadeo de red. La curva exponencial resuelve ambos extremos: rápida al principio, prudente después, con un techo que evita esperas absurdas.
delays: {
ESPERA: ({ context }) => {
const base = Math.min(20_000, 2 ** context.intentos * 400)
return Math.round(base / 2 + Math.random() * (base / 2))
},
}
Esa segunda línea es el jitter, y omitirlo es el error clásico. Sin aleatoriedad, todos los clientes que fallaron a la vez —porque el servidor cayó a la vez para todos— vuelven a llamar exactamente a la vez, y el reintento se convierte en una segunda oleada tan sincronizada como la primera. El jitter dispersa la oleada en el tiempo: cada cliente espera entre la mitad y el total de su ventana, y la avalancha se convierte en una lluvia fina que el servidor sí puede absorber mientras se recupera.
stateDiagram-v2 [*] --> inactivo inactivo --> cargando: CARGAR cargando --> exito: resultado cargando --> esperando: fallo con intentos restantes cargando --> agotado: fallo sin intentos esperando --> cargando: vence el retardo esperando --> inactivo: CANCELAR agotado --> cargando: reintento manual
El backoff es seguro sobre lecturas idempotentes y peligroso sobre escrituras. Si la petición creó un cobro y la respuesta se perdió en el camino, el reintento no repara nada: cobra dos veces. Antes de aplicar esta máquina a una mutación, exige una clave de idempotencia generada una sola vez —al entrar en el flujo, no en cada intento— y enviada en todos los intentos, para que el servidor reconozca el duplicado. Si el servidor no ofrece esa garantía, la política correcta no es reintentar sino llevar al usuario a un estado que consulte el resultado real antes de decidir.
Qué se reintenta y qué no
Reintentar todo es tan malo como no reintentar nada: multiplica la carga sin cambiar el desenlace y retrasa el mensaje honesto que el usuario merecía ver de inmediato. La guarda que decide debe mirar la clase del fallo, no solo el contador.
| Clase de fallo | Reintentar | Motivo |
|---|---|---|
| red caída o resolución fallida | sí | transitorio por definición |
| tiempo de espera agotado | sí, con techo | suele ser congestión pasajera |
| 429 con cabecera de espera | sí, respetando la cabecera | el servidor ya dijo cuándo volver |
| 500, 502 o 503 | sí | el fallo está del otro lado |
| 400 o 422 | no | la petición es inválida y repetirla no la corrige |
| 401 o 403 | no | pertenece al flujo de credenciales, no al de reintentos |
| 404 | no | el recurso no existe y no aparecerá esperando |
| aborto del usuario | no | fue una decisión deliberada, no un fallo |
guards: {
esTransitorio: ({ event }) => {
const status = (event as any).error?.status
return status === undefined || status === 429 || status >= 500
},
}
Con esa guarda encadenada antes de quedanIntentos, el grafo distingue tres desenlaces distintos ante un error: esperar y volver, rendirse tras agotar intentos, o fallar de inmediato porque el error nunca iba a curarse con tiempo. Ese tercer camino es el que casi ninguna implementación casera tiene, y es el que evita que un formulario mal rellenado tarde treinta segundos en decir que un campo era obligatorio.
Un 429 con indicación explícita de espera es la única fuente de verdad que supera a tu curva exponencial: el servidor conoce su propia recuperación mejor que tú. Guarda ese valor en el contexto desde la acción de error y haz que delays lo prefiera sobre el cálculo local cuando esté presente. Es una línea de código y convierte tu cliente en un vecino educado del sistema.
El reintento que no te pertenece
Antes de escribir todo lo anterior conviene comprobar si alguien ya lo hace. Una cache de estado de servidor trae reintentos con retardo configurable, y si tu petición es una lectura simple, la política de la librería es suficiente y no necesita grafo. La máquina aporta cuando el reintento deja de ser una decisión técnica invisible y pasa a ser parte de la experiencia: cuando el usuario ve la cuenta atrás, cuando puede cancelarla, cuando el agotamiento abre un camino alternativo en el flujo o cuando el número de intentos condiciona pasos posteriores del procedimiento.
Modelar el reintento como estados enseña algo que trasciende el reintento: en una máquina, el paso del tiempo no es una pausa en la ejecución sino un evento con derecho propio. Un await de espera detiene el programa y lo deja indefenso —no puede recibir órdenes, no puede informar, no puede rendirse antes de tiempo—, mientras que una transición temporizada mantiene la máquina viva y receptiva durante toda la espera: sigue en un estado con nombre, sigue aceptando eventos, sigue siendo interrogable. Esa diferencia parece de implementación y es de naturaleza, porque decide si tu sistema espera o si tu sistema está esperando. Lo primero es un hueco en el que no ocurre nada; lo segundo es una situación en la que el sistema se encuentra y sobre la que puede razonar. Y hay una segunda lección escondida en el contador: al sacar el número de intentos del cierre de una función y ponerlo en el contexto, conviertes la historia del proceso en dato accesible. La máquina ya no solo sabe dónde está, sabe cuánto le ha costado llegar, y esa memoria es lo que le permite decidir de forma distinta la cuarta vez que la primera. Un sistema que olvida sus intentos está condenado a repetirlos con la misma ingenuidad; uno que los recuerda puede aprender dentro de un mismo flujo, cambiar de estrategia, avisar antes de rendirse. Estados para el dónde, contexto para el cuánto ha costado, transiciones temporizadas para el mientras tanto: con esas tres piezas, la resiliencia deja de ser un truco defensivo enterrado en una utilidad y se convierte en parte declarada del modelo del dominio.
- Convierte un bucle de reintentos existente en dos estados —ejecutar y esperar— con el contador en el contexto y un destino de agotamiento.
- Sustituye la espera fija por una curva exponencial con techo definida en
delaysy comprueba los tiempos reales en el inspector. - Añade jitter y razona en voz alta qué le ocurriría a tu servidor si mil clientes fallaran en el mismo segundo sin él.
- Escribe la guarda que distingue fallos transitorios de definitivos y verifica que un 422 falla al instante en vez de esperar tres rondas.
- Captura la cabecera de espera de un 429 en el contexto y haz que la política la prefiera sobre tu cálculo local.
- Prueba que salir del estado de espera con
CANCELARdescarta el temporizador y que nada vuelve a dispararse después.