Saltar al contenido
Mathias Parodi

4 min de lectura

El reintento que tira abajo el sistema

Reintentar es lo primero que hacemos cuando algo falla y lo último que medimos. Una nota corta sobre jitter, presupuestos de reintento y por qué el backoff exponencial solo no alcanza.

Un servicio de abajo se pone lento. Los de arriba empiezan a dar timeout. Todos reintentan. El servicio de abajo, que estaba lento, ahora recibe el doble de tráfico. Se pone más lento. Todos reintentan más.

Es la falla más aburrida y más común que existe, y el código que la produce siempre parece razonable en el code review.

Backoff exponencial no alcanza

La primera respuesta que todos conocemos es esperar más entre intento e intento: 100 ms, 200 ms, 400 ms, 800 ms. Está bien, pero resuelve solo la mitad del problema.

Si mil clientes fallan al mismo tiempo — porque el de abajo se cayó al mismo tiempo para todos —, los mil van a reintentar a los 100 ms. Y a los 200. Y a los 400. El backoff no dispersa la manada: la sincroniza y la hace llegar en oleadas cada vez más espaciadas pero igual de altas.

Lo que dispersa es el jitter.

retry/backoff.go
// Backoff exponencial "full jitter": el intervalo no crece, crece el techo
// del cual se sortea. Dos clientes que fallan en el mismo milisegundo
// reintentan en momentos distintos.
func espera(intento int, base, techo time.Duration) time.Duration {
	limite := techo
 
	// Recortar ANTES de desplazar. Al revés — desplazar y después comparar
	// contra el techo — `base << intento` desborda el int64 con muchos
	// menos intentos de los que uno imagina, y el techo llega tarde.
	if base > 0 && intento >= 0 && intento < 63 && base <= techo>>intento {
		limite = base << intento
	}
 
	// rand.Int63n entra en pánico si recibe cero o un negativo, así que el
	// límite tiene que ser positivo por construcción y no por suerte.
	if limite <= 0 {
		return 0
	}
 
	// La clave está acá: sorteamos en [0, limite), no esperamos limite.
	return time.Duration(rand.Int63n(int64(limite)))
}

Los dos detalles que hacen que esa función no explote son fáciles de saltear, y la versión que casi todos escribimos primero se los saltea. El primero es recortar antes de desplazar: base << intento sobre un int64 desborda mucho antes de que un techo aplicado después llegue a hacer su trabajo, y un límite dado vuelta queda negativo. El segundo es que rand.Int63n no acepta cero ni negativos: entra en pánico. Los dos juntos hacen que, pasado cierto número de intentos, la función deje de ser lenta y pase a ser una caída — y la caída la provoca el código que estaba puesto ahí para sobrevivir a las caídas. Comparar base <= techo>>intento hace exactamente la misma cuenta que el desplazamiento, sin llegar a desbordar nunca.

Hay variantes — equal jitter, decorrelated jitter — y la diferencia entre ellas importa mucho menos que la diferencia entre tener jitter y no tenerlo.El análisis clásico es “Exponential Backoff And Jitter”, en el blog de arquitectura de AWS: simula las variantes y compara cuántos intentos y cuánto tiempo total hace falta con cada una. La valoración que sigue es mía y no del artículo: full jitter es la opción por defecto razonable, porque casi todo lo que la mejora la mejora poco y cuesta bastante más código.

Presupuesto de reintentos

El jitter arregla la forma de la ola. No arregla el volumen: si cada llamada admite hasta tres intentos, un servicio que se degrada recibe hasta el triple de su tráfico normal justo cuando menos puede soportarlo.

La herramienta para eso es un presupuesto: un tope de reintentos como fracción del tráfico exitoso, evaluado en una ventana móvil. Si en los últimos diez segundos hubo 1.000 pedidos bien y el presupuesto es 10 %, se permiten 100 reintentos. El 101 falla directo, sin tocar la red.

retry/presupuesto.go
type Presupuesto struct {
	ratio    float64 // p. ej. 0.10
	minimo   int     // piso para tráfico bajo
	ventana  *ventanaMovil
}
 
func (p *Presupuesto) Permite() bool {
	exitosos, reintentos := p.ventana.Contar()
	tope := float64(exitosos)*p.ratio + float64(p.minimo)
	return float64(reintentos) < tope
}

Es incómodo la primera vez que se ve, porque significa devolver un error que podríamos haber evitado. Pero el error que devolvemos rápido lo paga un cliente; la avalancha que evitamos la pagan todos.

Qué reintentar

Y la parte que casi siempre se saltea: no todo error merece un reintento.

Situación¿Reintentar?Por qué
Timeout de conexiónProbablemente nunca llegó
Timeout de lecturaSolo si es idempotentePuede haberse ejecutado
429 / 503 con Retry-AfterSí, respetando el headerEl servidor te está diciendo cuándo
500Con cuidadoPuede ser determinístico y no cambiar nunca
400, 422NoReintentar un pedido inválido lo mantiene inválido
409 de idempotenciaSí, con backoffHay otro intento en vuelo

La fila que más cuesta aceptar es la segunda. Un timeout de lectura no dice nada sobre si la operación ocurrió; solo dice que no nos enteramos. Reintentar eso sin una clave de idempotencia es cómo se cobra dos veces.

Tres reglas

  1. Jitter siempre. Backoff sin jitter sincroniza la manada.
  2. Presupuesto de reintentos, no un contador por llamada.
  3. Reintentá en el borde, no en cada capa.

Y una cuarta que no es técnica: medí los reintentos como una métrica de primera clase, separada de los pedidos. Si tu tablero muestra “requests por segundo” sin distinguir intentos de reintentos, no vas a ver la avalancha hasta que ya te pasó por arriba.