Hay una decisión que tomé mal más de una vez: agregar retry como si fuera una mejora sin costo. Configuro tres intentos con backoff exponencial, el sistema se ve más estable en el dashboard, y listo. Lo que no estaba mirando era cuántas llamadas extra le estaba mandando al downstream en cada falla.
Este post nace de un experimento que armé para medir eso con precisión: cuándo retry compra disponibilidad real, cuándo multiplica presión y cuándo simplemente no cambia nada porque el problema no es transitorio. El repo es retry-resilience-experiment, commit bdfc350, con Spring Boot 3.3.5, Java 21, Resilience4j 2.2.0 y k6 como generador de carga.
Mi tesis es simple: retry es presupuesto. Cada intento extra consume tiempo de espera del usuario, llama al downstream real y puede acelerar una degradación que ya estaba en curso. No es una feature que activás y listo.
El problema de mirar solo el success rate
Cuando el downstream tiene fallas aleatorias simuladas al 35%, la diferencia entre políticas es visible. Con no-retry-standard-timeout, el success rate en esa corrida fue 0.6529. Con immediate-retry, subió a 0.955. Eso parece una victoria clara.
Pero el número que importa está al lado: el retry_amplification_factor. Con immediate-retry en random-failures llegó a 1.465. Eso significa que por cada request del usuario, el sistema hizo 1.465 llamadas reales al downstream. En jitter-random-failures fue 1.471. El downstream recibió casi un 47% más de tráfico del que generó k6.
En fallas transitorias eso puede ser aceptable. El downstream está fallando por razones externas, los reintentos aterrizan en momentos distintos y el resultado mejora. Pero ese 47% extra no es abstracto: tiene que existir capacidad downstream para absorberlo. Si el servicio ya está al límite, ese overhead es el empujón que lo tira.
La métrica que el repo define como contrato para no engañarse es exactamente esa:
// MetricSnapshot.java — la razón de esta línea es evitar autoengaño
double retryAmplificationFactor, // downstream_calls / total_requestsSi solo mirás successRate y errorRate, podés creer que ganaste cuando en realidad le metiste 47% más de carga a un sistema que ya estaba sufriendo.
progressive-degradation: donde el retry puede acelerar la caída
Este escenario es el más interesante metodológicamente, y también el que tiene la advertencia más importante.
El downstream de PROGRESSIVE_DEGRADATION implementa esto:
// DownstreamScenario.java — el delay sube con cada llamada real recibida
case PROGRESSIVE_DEGRADATION ->
Duration.ofMillis(Math.min(900, 80 + callNumber * 3));El delay no es externo ni fijo: crece con callNumber, que es el contador de llamadas reales al downstream. Esto significa que una política con más retries genera más llamadas, y esas llamadas aceleran la degradación. No es la misma falla para todos: las políticas con retry se degradan más rápido porque presionan más.
Los números de la corrida muestran eso claramente. Con no-retry-standard-timeout se procesaron 7720 requests totales y se iniciaron 7720 llamadas downstream. Con immediate-retry, los requests totales bajaron a 2939 pero las llamadas downstream subieron a 8699, con un amplification factor de 2.96. La policy con retry procesó menos requests de usuarios pero le hizo más llamadas al downstream.
Ahora bien: esto no es un fallo de diseño, es el punto del experimento. El laboratorio lo documenta explícitamente en docs/brief-post.md: progressive-degradation debe leerse como degradación sensible a carga, no como falla externa idéntica para todos. Si lo tratás como comparación directa entre políticas bajo las mismas condiciones, la conclusión está mal planteada desde el vantage point.
Lo que sí podés concluir: en escenarios donde la velocidad de degradación depende del volumen de llamadas recibidas, los retries pueden ser un acelerador de la caída. Eso tiene nombre en producción: retry storm. Y el laboratorio lo reproduce de forma controlada.
Los percentiles que te mienten cuando hay timeouts
Hay un detalle técnico que cambió mi forma de leer los resultados, y que el README documenta con honestidad.
El timeout del caller se implementa con future.cancel(true) en el RetryExecutor:
// RetryExecutor.java — el cancel(true) interrumpe el intento desde el caller
try {
future.get(policy.timeout().toMillis(), TimeUnit.MILLISECONDS);
return new AttemptResult(true, elapsedMs(started), "ok", true);
} catch (TimeoutException timeout) {
future.cancel(true);
return new AttemptResult(false, elapsedMs(started), "timeout", true);
}Cuando un intento vence el timeout, la latencia registrada para ese intento está capada por el timeout del caller: STANDARD_TIMEOUT = Duration.ofMillis(260). Por eso en progressive-degradation casi todos los all_attempt_p95_ms y all_attempt_p99_ms muestran exactamente 260. No es que el downstream respondió en 260 ms: es que el caller dejó de esperar a los 260 ms y registró eso como latencia del intento.
Lo que pasa después del cancel(true) en el downstream simulado no se modela completamente. En un sistema real con HTTP, base de datos o cola, el downstream puede seguir ejecutando trabajo aunque el cliente ya no espere. El laboratorio cuenta llamadas iniciadas, pero no puede garantizar que no hay trabajo residual post-cancelación.
Esto importa para leer successful_requests_per_second también. El valor de 0.95 que aparece en varios escenarios de progressive-degradation no es la capacidad máxima del sistema: es el trabajo útil observado bajo esa carga cerrada de k6. Con otra configuración de VUs, otra duración o una red real, los números serían distintos.
circuit-breaker y bulkhead: rechazos visibles como señal de protección
En progressive-degradation, el circuit breaker produce algo que parece contradictorio al primer vistazo. La corrida 13-circuit-breaker-progressive-degradation tiene total_requests = 44777 y circuit_breaker_rejected = 44718. El error rate es 0.9987. Eso parece catastrófico.
Pero mirá las llamadas downstream: 198. Amplification factor: 0.004. El circuit breaker dejó de mandar llamadas al downstream casi por completo. Los rechazos son visibles hacia el cliente, pero el downstream está protegido.
Si comparás con immediate-retry-progressive-degradation, que tiene downstream_calls = 8699 y sigue fallando igual, el trade-off se hace evidente. El circuit breaker elige rechazar rápido antes que multiplicar presión sobre algo que ya no puede responder.
El bulkhead en la misma corrida muestra una variante distinta: bulkhead_rejected = 22122 con downstream_calls = 3668. Limita concurrencia en lugar de cortar el circuito, pero el efecto es similar: reduce presión downstream a costa de rechazos visibles.
Esas señales de concurrencia (max_inflight_downstream = 16 para bulkhead, 40 para la mayoría de las otras corridas) son observaciones, no prueba de saturación. El laboratorio renombró la métrica de saturationObservation a concurrencyObservation exactamente por eso: max_inflight alto no prueba saturación de CPU, red ni pool de conexiones. Es una señal que invita a investigar, no una conclusión.
Qué concluyo y qué no
Este experimento es una simulación local, corrida única publicada, sobre un downstream simulado con delays en memoria. Los números no representan producción, no representan ningún proveedor real y no permiten afirmar "esta política escala a X RPS". Si querés publicar valores exactos con claims fuertes, el README lo dice claramente: hacé al menos tres corridas editorial y mirá consistencia, no una sola pasada.
Lo que sí creo que puede sostenerse:
- En fallas transitorias, retry puede mejorar success rate pero siempre tiene un amplification factor mayor a 1. Ese overhead existe y tiene que caber en el sistema.
- En degradación sensible a carga, más retries pueden acelerar la degradación porque generan más llamadas. Esto no es universal, pero el escenario es real y el experimento lo reproduce.
- p95 y p99 de intentos no te cuentan la latencia real del downstream cuando hay timeouts: te cuentan cuánto esperó el caller antes de rendirse.
- Circuit breaker y bulkhead producen rechazos visibles que pueden ser exactamente la decisión correcta para proteger el sistema.
Lo que no concluyo: que una política es mejor que otra en abstracto, que estos números aplican a otro sistema, o que max_inflight_downstream prueba saturación.
La pregunta que me dejo para seguir explorando: ¿cuánto trabajo residual real queda en el downstream después de un future.cancel(true) en un sistema con pool de conexiones HTTP? El laboratorio lo anota como limitación conocida. En producción eso es exactamente donde está la diferencia entre un timeout que protege y uno que solo esconde el problema.
El repo está en github.com/JuanTorchia/retry-resilience-experiment. Si lo corrés y obtenés números distintos, me interesa saberlo.
Artículos Relacionados
HikariCP: el p95 que te miente y cómo leer las señales reales del pool
Un p95 bajo con 97% de error rate no es un pool rápido: es un pool que falla rápido. Armé un experimento reproducible con Spring Boot 3, PostgreSQL y k6 para entender qué señales importan de verdad — y cuáles te engañan.
Spring Security con Spring Boot Actuator: así quedó el modelo de autorización después del incidente
Cerrar los endpoints de Actuator no alcanza. Después del incidente, reconstruí el modelo de autorización desde cero: SecurityFilterChain explícito, health groups separados, roles para /metrics y /env, y validación real con curl. Esto es lo que quedó en pie.
Spring Boot Actuator en producción: los endpoints que dejé abiertos sin darme cuenta y cómo los cerré
Después de publicar el análisis de Jakarta EE vs Spring Boot, revisé los defaults de Actuator en un backend propio y encontré endpoints sensibles abiertos que nunca configuré conscientemente. Acá está el checklist de hardening que armé después.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.