Ir al contenido
wannacode

Cuando el vecino es lento: timeout, retry, circuit breaker

En resumen: Un timeout corta la espera de un servicio lento, un retry con espera exponencial y jitter repite solo los errores temporales, y un circuit breaker detiene las solicitudes hasta que el servicio se recupera.

Problema

Treinta segundos que detuvieron la tienda

En la mañana del Black Friday el proveedor de pagos empieza a responder en 30 segundos en vez de 300 ms. No se cae: responde, pero despacio. Un minuto después la tienda ni siquiera muestra el catálogo, y el catálogo nunca llama al proveedor.

Aquí está el porqué, en pequeño. La tienda tiene un pool de 4 hilos (la lección sobre pools de hilos). En cada tick llegan un pago y una solicitud del catálogo. El proveedor responde en 30 ticks:

// pool de 4 hilos; en cada tick llegan un pago y una solicitud del catálogo
static void Run(string title, int timeout)
{
var busyUntil = new int[4]; // hasta qué tick está ocupado cada hilo
int refused = 0;
for (int t = 0; t < 8; t++)
{
int free = Array.FindIndex(busyUntil, u => u <= t);
// el pago retiene el hilo hasta que el proveedor responde
if (free >= 0) busyUntil[free] = t + Math.Min(30, timeout);
if (Array.FindIndex(busyUntil, u => u <= t) < 0) refused++; // el catálogo no tiene un hilo libre
}
int busy = busyUntil.Count(u => u > 7);
Console.WriteLine($"{title}: hilos ocupados {busy} de 4, el catálogo rechazó {refused} solicitudes");
}
Run("sin timeout", int.MaxValue);
Salida
sin timeout: hilos ocupados 4 de 4, el catálogo rechazó 5 solicitudes

Después de cuatro ticks los cuatro hilos esperan al proveedor. A partir de ahí el catálogo rechaza todo, aunque no tiene nada que ver con los pagos. Un vecino lento es más peligroso que uno muerto: el muerto al menos responde enseguida con un error.

La idea

Timeout: no esperes para siempre

Un timeout es el tiempo máximo que esperas una respuesta. Pasado ese tiempo, paras, liberas el hilo y le dices al cliente algo claro: «El pago no se completó, el pedido está guardado, inténtalo de nuevo en un momento». La misma tienda con un timeout de 2 ticks:

Run("timeout de 2 ticks", 2);
Salida
timeout de 2 ticks: hilos ocupados 2 de 4, el catálogo rechazó 0 solicitudes

Los pagos siguen fallando, porque el proveedor es lento. Pero cada uno retiene un hilo como máximo 2 ticks, siempre hay hilos libres, y el catálogo funciona.

¿Cuánto debe durar el timeout? Sale del presupuesto de latencia: si el pago tiene 450 ms para el p95, un timeout de 30 segundos no protege nada. Normalmente queda un poco por encima del p99 del vecino, medido, no adivinado. Y el timeout de la capa inferior debe ser más corto que el de la superior: si no, la capa de arriba ya se rindió mientras la de abajo sigue trabajando para nadie.

La idea

Retry: intenta de nuevo, pero con cabeza

Muchos errores son temporales: un paquete perdido, un servidor que se reinicia, una respuesta 503 («estoy ocupado»). Un segundo intento un poco después suele funcionar. Eso es un retry. Tres reglas lo hacen seguro:

  1. Solo errores temporales. Se reintentan timeout y 503. «La tarjeta fue rechazada» o 400 (solicitud mal formada) será lo mismo en el segundo intento.
  2. Una espera que crece: 1, 2, 4, 8 ticks. Esto es la espera exponencial (exponential backoff): un vecino que se está ahogando recibe cada vez más aire.
  3. Un límite de intentos. Después te rindes y devuelves el último error.
// solo los errores temporales: el proveedor puede recuperarse
static bool Retryable(string answer) =>
answer is "timeout" or "503";
static string WithRetry(List<string> answers, int maxAttempts)
{
int tick = 0;
for (int attempt = 1; ; attempt++)
{
string answer = answers[attempt - 1]; // la respuesta grabada del intento número attempt
Console.WriteLine($"tick {tick}, intento {attempt}: {answer}");
if (!Retryable(answer) || attempt == maxAttempts) return answer;
tick += 1 << (attempt - 1); // espera de 1, 2, 4, 8… ticks
}
}

El proveedor responde según un guion grabado: timeout, luego 503, luego ok.

Console.WriteLine("resultado: " + WithRetry(["timeout", "503", "ok"], 4));
Salida
tick 0, intento 1: timeout
tick 1, intento 2: 503
tick 3, intento 3: ok
resultado: ok

Ojo: un pago repetido puede cobrar el dinero dos veces si el primero pasó y solo se perdió la respuesta. Por eso el pago se reintenta únicamente con una clave de idempotencia: el proveedor reconoce la solicitud repetida y devuelve el primer resultado.

Pruébalo

Predice la salida

El proveedor responde timeout tres veces y ok en el cuarto intento. El límite es de 3 intentos. ¿Qué imprime el programa?

Console.WriteLine("resultado: " + WithRetry(["timeout", "timeout", "timeout", "ok"], 3));
Respuesta

Pruébalo

Encuentra el error

Un cliente con la tarjeta rechazada espera nada menos que 7 ticks, y el banco envía cuatro avisos de rechazo. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Jitter: para que no arranquen todos juntos

Cuando el proveedor falla, miles de pagos fallan a la vez. Con espera exponencial todos reintentan en el tick 1, luego en el 3, luego en el 7: juntos, justo cuando el proveedor intenta recuperarse. Es la manada atronadora (thundering herd).

El jitter añade aleatoriedad a la espera: cada cliente espera un número aleatorio de ticks, de 1 al doble de la espera. El programa usa un generador pequeño con semilla, para que dé los mismos números en cada lenguaje y en cada ejecución:

int seed = 7;
// generador con semilla: los mismos números en cada lenguaje
int Random(int bound)
{
seed = (seed * 75 + 74) % 65537;
return seed % bound;
}
string RetryTicks(bool jitter)
{
var ticks = new List<int>();
int tick = 0;
for (int attempt = 0; attempt < 4; attempt++)
{
int wait = 1 << attempt; // 1, 2, 4, 8
if (jitter) wait = 1 + Random(2 * wait); // de 1 a 2 · wait
tick += wait;
ticks.Add(tick);
}
return string.Join(" ", ticks);
}
foreach (var client in new[] { "A", "B", "C" })
Console.WriteLine($"{client} sin jitter: {RetryTicks(false)}");
foreach (var client in new[] { "A", "B", "C" })
Console.WriteLine($"{client} con jitter: {RetryTicks(true)}");
Salida
A sin jitter: 1 3 7 15
B sin jitter: 1 3 7 15
C sin jitter: 1 3 7 15
A con jitter: 2 6 11 13
B con jitter: 2 5 12 19
C con jitter: 1 5 8 9

Sin jitter, los tres golpean al proveedor en los mismos ticks. Con jitter se reparten. En código real no fijas una semilla: usas el generador del lenguaje, y en los tests pasas el tuyo para que la salida sea predecible.

La idea

Circuit breaker: deja de llamar

Un retry ayuda con un problema corto. Si el proveedor lleva diez minutos caído, cada reintento es una solicitud más a alguien que se ahoga, y un hilo más que espera. Entonces necesitas un circuit breaker, como el fusible de un cuadro eléctrico. Tiene tres estados:

  • cerrado (closed): las solicitudes pasan. El breaker cuenta los fallos seguidos;
  • abierto (open): tras 3 fallos seguidos deja de llamar. Cada solicitud se rechaza de inmediato, sin esperar;
  • semiabierto (half-open): después de 4 ticks deja pasar una prueba. Si funciona, el breaker se cierra. Si falla, se abre otra vez.
enum State { Closed, Open, HalfOpen }
class CircuitBreaker
{
const int Threshold = 3; // errores seguidos hasta que se abre
const int Cooldown = 4; // ticks en OPEN antes de una prueba
public State State { get; private set; } = State.Closed;
int failures;
int openedAt;
public int Calls { get; private set; } // cuántas veces se llamó al proveedor
public string Call(int tick, bool providerUp)
{
if (State == State.Open)
{
if (tick - openedAt < Cooldown)
return "rechazado de inmediato";
State = State.HalfOpen; // una prueba
}
Calls++;
if (providerUp)
{
State = State.Closed;
failures = 0;
return "ok";
}
failures++;
bool tooMany = failures >= Threshold;
if (State == State.HalfOpen || tooMany)
{
State = State.Open;
openedAt = tick;
}
return "error";
}
}

Míralo

El breaker durante 12 ticks

public string Call(int tick, bool providerUp)
{
if (State == State.Open)
{
if (tick - openedAt < Cooldown)
return "rechazado de inmediato";
State = State.HalfOpen; // una prueba
}
Calls++;
if (providerUp)
{
State = State.Closed;
failures = 0;
return "ok";
}
failures++;
bool tooMany = failures >= Threshold;
if (State == State.HalfOpen || tooMany)
{
State = State.Open;
openedAt = tick;
}
return "error";
}
Paso a paso, en palabras
  1. Closed: cada llamada va al proveedor de pagos. El proveedor no responde: el tick 0 y el tick 1 son errores, el contador está en 2 de 3.
  2. Tick 2: el tercer error seguido. El breaker se abre (Open) y recuerda cuándo.
  3. Los ticks 3, 4 y 5 se rechazan de inmediato. No llegan al proveedor y no retienen ningún hilo. La tienda puede ofrecer enseguida «paga después».
  4. Tick 6: pasaron 4 ticks. HalfOpen: pasa una prueba. El proveedor sigue caído, así que el breaker se abre otra vez.
  5. Desde el tick 7 el proveedor vuelve a funcionar, pero el breaker aún no lo sabe. Los ticks 7 a 9 se rechazan: el precio de esperar.
  6. Tick 10: la prueba funciona, el breaker se cierra (Closed) y el contador se reinicia. Se llamó al proveedor 6 veces de 12.

El proveedor no responde hasta el tick 7 y funciona después. El guion grabado es tick >= 7:

var breaker = new CircuitBreaker();
for (int tick = 0; tick < 12; tick++)
{
bool up = tick >= 7; // grabado: el proveedor vuelve en el tick 7
string result = breaker.Call(tick, up);
Console.WriteLine($"tick {tick}: {result} → {breaker.State}");
}
Console.WriteLine($"llamadas al proveedor: {breaker.Calls} de 12");
Salida
tick 0: error → Closed
tick 1: error → Closed
tick 2: error → Open
tick 3: rechazado de inmediato → Open
tick 4: rechazado de inmediato → Open
tick 5: rechazado de inmediato → Open
tick 6: error → Open
tick 7: rechazado de inmediato → Open
tick 8: rechazado de inmediato → Open
tick 9: rechazado de inmediato → Open
tick 10: ok → Closed
tick 11: ok → Closed
llamadas al proveedor: 6 de 12

De 12 solicitudes solo 6 llegan al proveedor. Las demás se rechazan de inmediato: no retienen ningún hilo y no cargan a alguien que de todos modos no responde. El precio se ve en los ticks 7 a 9: el proveedor ya funciona, y el breaker aún rechaza. Un cooldown más corto devuelve el trabajo antes, pero golpea al proveedor más a menudo.

La propiedad State es del tipo State. C# lo permite: en State == State.Open el compilador entiende que a la izquierda está la propiedad y a la derecha el enum.

Pruébalo

Completa

¿Cuántos ticks espera el breaker abierto antes de dejar pasar una prueba?

if (State == State.Open)
{
    if (tick - openedAt < )
        return "rechazado de inmediato";
    State = State.HalfOpen;
}

La idea

Bulkhead y la respuesta ante el rechazo

Un bulkhead (como los mamparos del casco de un barco) es un pool de hilos aparte para cada vecino: el pago tiene sus propios 4 hilos y el catálogo los suyos. Así un proveedor lento solo se come los suyos.

El breaker y el timeout dicen «ahora no», y el sistema debe saber qué hacer entonces. Eso es el fallback: el pedido se guarda como «esperando el pago» y el cliente paga después; la página muestra el precio desde la caché; el pago pasa por un segundo proveedor. Qué respuesta es aceptable lo decide el negocio, como con los trade-offs.

La idea

Cuando duele

  • Reintentos en cada capa. El gateway reintenta 3 veces, los pedidos 3 veces, el pago 3 veces: hasta 27 solicitudes al proveedor por un clic. Reintenta en un solo lugar.
  • Reintentar una operación que no es idempotente. Un pago doble, dos correos, dos paquetes. Sin clave de idempotencia, no reintentes.
  • Un timeout más largo que el de quien llama. La capa de arriba ya le dijo «error» al cliente, y la de abajo aún espera y reintenta.
  • Un breaker para todo el servicio por una sola dirección lenta. Si solo la búsqueda es lenta, no detengas también el pago.
  • Números adivinados, no medidos. Un umbral demasiado bajo abre el breaker con cualquier ruido; un cooldown demasiado largo lo mantiene abierto cuando el vecino lleva rato funcionando.

Estos mismos mecanismos suele ofrecerlos también la infraestructura (un service mesh como Istio o Linkerd, un API gateway). Eso se enseña en el curso de DevOps ZeroToOps. Aquí vemos la parte en el código: qué haces cuando el vecino no responde.

Más precisamente, para curiosos

Las bibliotecas. En un proyecto real no escribes un breaker tú mismo: Java tiene Resilience4j (Spring Cloud Circuit Breaker lo usa); C# tiene Polly y Microsoft.Extensions.Http.Resilience para HttpClient; Python tiene tenacity para reintentos y pybreaker; Node tiene cockatiel y opossum. Hystrix de Netflix es el modelo histórico y ya no se desarrolla.

Los timeouts por defecto. En .NET HttpClient espera 100 segundos por defecto. En el HttpClient de Java y en requests de Python no hay timeout hasta que pones uno. En Node fetch (undici) espera las cabeceras hasta 300 segundos. Conociste los clientes de red en la lección «Programación en red».

El plazo viaja. Si el cliente tiene 2 segundos en total y el gateway ya gastó 0,5, el servicio de abajo recibe 1,5, no sus propios 2. gRPC lo hace con un deadline que viaja con la solicitud.

Retry-After y un presupuesto de reintentos. Las respuestas 503 y 429 pueden decir cuándo intentarlo (Retry-After). Muchos sistemas también limitan los reintentos en total, por ejemplo al 10 % de las solicitudes, para no duplicar la carga justo cuando está peor.

Tipos de jitter. Aquí es jitter «full»: aleatorio de 1 al doble. También existe el jitter «equal» (mitad fija, mitad aleatoria) y el «decorrelated». El objetivo es el mismo: los clientes no deben sincronizarse.

El generador de jitter es una función local del código de nivel superior y ve la variable seed de fuera.

Ejercicio

Reintentos con un plazo

Resuelve la tarea en tu propio editor.

El cliente espera el pago como máximo 5 ticks. Escribe WithDeadline, que reintenta como WithRetry, pero sin límite de intentos: se detiene cuando el siguiente intento caería después del plazo, y dice por qué. El proveedor responde timeout tres veces, luego ok.

Salida
tick 0, intento 1: timeout
tick 1, intento 2: timeout
tick 3, intento 3: timeout
me detengo: intento 4 sería en el tick 7, después del plazo 5
resultado: timeout

Más tareas

  • Un breaker y reintentos juntos. Pon el breaker delante de WithRetry. ¿Es un rechazo «de inmediato» un error temporal?
  • Un bulkhead. Divide el pool del problema en dos: 2 hilos para los pagos y 2 para el catálogo. ¿Cuántas solicitudes del catálogo se rechazan ahora sin timeout?

Test

Ponte a prueba

1. ¿Por qué un proveedor lento es más peligroso que uno que no funciona en absoluto?
2. ¿Qué errores vale la pena repetir?
3. ¿Por qué se añade jitter a la espera?
4. El breaker está abierto. ¿Qué pasa con una solicitud nueva antes de que pase el cooldown?
5. Tres capas una sobre otra, y cada una reintenta hasta 3 veces. ¿Cuántas solicitudes pueden llegar a la de abajo si no responde?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·