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álogostatic void run(String title, int timeout) { int[] busyUntil = new int[4]; // hasta qué tick está ocupado cada hilo int refused = 0; for (int t = 0; t < 8; t++) { int free = freeThread(busyUntil, t); // el pago retiene el hilo hasta que el proveedor responde if (free >= 0) busyUntil[free] = t + Math.min(30, timeout); if (freeThread(busyUntil, t) < 0) refused++; // el catálogo no tiene un hilo libre } long busy = Arrays.stream(busyUntil).filter(u -> u > 7).count(); System.out.println(title + ": hilos ocupados " + busy + " de 4, el catálogo rechazó " + refused + " solicitudes");}
static int freeThread(int[] busyUntil, int t) { for (int i = 0; i < busyUntil.length; i++) if (busyUntil[i] <= t) return i; return -1;}run("sin timeout", Integer.MAX_VALUE);sin timeout: hilos ocupados 4 de 4, el catálogo rechazó 5 solicitudesDespué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);timeout de 2 ticks: hilos ocupados 2 de 4, el catálogo rechazó 0 solicitudesLos 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:
- Solo errores temporales. Se reintentan timeout y 503. «La tarjeta fue rechazada» o 400 (solicitud mal formada) será lo mismo en el segundo intento.
- 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.
- Un límite de intentos. Después te rindes y devuelves el último error.
// solo los errores temporales: el proveedor puede recuperarsestatic boolean retryable(String answer) { return answer.equals("timeout") || answer.equals("503");}static String withRetry(List<String> answers, int maxAttempts) { int tick = 0; for (int attempt = 1; ; attempt++) { String answer = answers.get(attempt - 1); // la respuesta grabada del intento número attempt System.out.println("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.
System.out.println("resultado: " + withRetry(List.of("timeout", "503", "ok"), 4));tick 0, intento 1: timeouttick 1, intento 2: 503tick 3, intento 3: okresultado: okOjo: 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?
System.out.println("resultado: " + withRetry(List.of("timeout", "timeout", "timeout", "ok"), 3));Los intentos caen en los ticks 0, 1 y 3 (una espera de 1, luego 2). El tercero es el último permitido, así que la función devuelve timeout sin llegar al ok del cuarto.
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.
Todo menos ok cuenta como temporal, también la tarjeta rechazada. Esperar no lo arregla. Solo se reintentan timeout y 503; lo demás se devuelve enseguida.
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:
static int seed = 7;
// generador con semilla: los mismos números en cada lenguajestatic int random(int bound) { seed = (seed * 75 + 74) % 65537; return seed % bound;}
static String retryTicks(boolean jitter) { StringJoiner ticks = new StringJoiner(" "); 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(String.valueOf(tick)); } return ticks.toString();}for (String client : List.of("A", "B", "C")) System.out.println(client + " sin jitter: " + retryTicks(false));for (String client : List.of("A", "B", "C")) System.out.println(client + " con jitter: " + retryTicks(true));A sin jitter: 1 3 7 15B sin jitter: 1 3 7 15C sin jitter: 1 3 7 15A con jitter: 2 6 11 13B con jitter: 2 5 12 19C con jitter: 1 5 8 9Sin 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, HALF_OPEN }
class CircuitBreaker { static final int THRESHOLD = 3; // errores seguidos hasta que se abre static final int COOLDOWN = 4; // ticks en OPEN antes de una prueba State state = State.CLOSED; int failures = 0; int openedAt = 0; int calls = 0; // cuántas veces se llamó al proveedor
String call(int tick, boolean providerUp) { if (state == State.OPEN) { if (tick - openedAt < COOLDOWN) return "rechazado de inmediato"; state = State.HALF_OPEN; // una prueba } calls++; if (providerUp) { state = State.CLOSED; failures = 0; return "ok"; } failures++; boolean tooMany = failures >= THRESHOLD; if (state == State.HALF_OPEN || tooMany) { state = State.OPEN; openedAt = tick; } return "error"; }}Míralo
El breaker durante 12 ticks
String call(int tick, boolean providerUp) { if (state == State.OPEN) { if (tick - openedAt < COOLDOWN) return "rechazado de inmediato"; state = State.HALF_OPEN; // una prueba } calls++; if (providerUp) { state = State.CLOSED; failures = 0; return "ok"; } failures++; boolean tooMany = failures >= THRESHOLD; if (state == State.HALF_OPEN || tooMany) { state = State.OPEN; openedAt = tick; } return "error";}Paso a paso, en palabras
- 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.
- Tick 2: el tercer error seguido. El breaker se abre (OPEN) y recuerda cuándo.
- 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».
- Tick 6: pasaron 4 ticks. HALF_OPEN: pasa una prueba. El proveedor sigue caído, así que el breaker se abre otra vez.
- 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.
- 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++) { boolean up = tick >= 7; // grabado: el proveedor vuelve en el tick 7 String result = breaker.call(tick, up); System.out.println("tick " + tick + ": " + result + " → " + breaker.state);}System.out.println("llamadas al proveedor: " + breaker.calls + " de 12");tick 0: error → CLOSEDtick 1: error → CLOSEDtick 2: error → OPENtick 3: rechazado de inmediato → OPENtick 4: rechazado de inmediato → OPENtick 5: rechazado de inmediato → OPENtick 6: error → OPENtick 7: rechazado de inmediato → OPENtick 8: rechazado de inmediato → OPENtick 9: rechazado de inmediato → OPENtick 10: ok → CLOSEDtick 11: ok → CLOSEDllamadas al proveedor: 6 de 12De 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.
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.HALF_OPEN;
}El cooldown. Mientras no haya pasado, cada solicitud se rechaza de inmediato. Después el breaker queda semiabierto y deja pasar una prueba.
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.
1 << (attempt - 1) es 2 elevado a attempt − 1, desplazando bits. StringJoiner une los números con un espacio.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.
tick 0, intento 1: timeouttick 1, intento 2: timeouttick 3, intento 3: timeoutme detengo: intento 4 sería en el tick 7, después del plazo 5resultado: timeoutMá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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Programación en red · Grupo de hilos y tareas · Diseño de API: contrato, versiones, solicitudes repetidas · Requisitos no funcionales que puedes medir
- Continúa en: Una transacción entre servicios (Saga) · Integrar sistemas antiguos y ajenos · Revisión de arquitectura · Tu primera tarea de arquitectura
- Ejercicios sobre el tema: El arquitecto en acción