@Retryable en Spring Boot 4: guía completa con @Recover y backoff [2026]

2026-06-03
Spring BootJavaSpring FrameworkBackend

@Retryable es la anotación de Spring para añadir lógica de reintento automático a cualquier método de servicio. En Spring Framework 7 (Spring Boot 4) está integrada en el core del framework: no necesitas la dependencia externa spring-retry. Esta guía cubre todos los casos de uso con código listo para producción — desde el uso básico hasta backoff exponencial, @Recover, métricas con Actuator y testing robusto.

¿Cuándo usar @Retryable? {#cuando-usar}

@Retryable es la solución correcta para fallos transitorios: errores que ocurren puntualmente y que desaparecen en el siguiente intento sin necesidad de intervención humana. Los casos más frecuentes en producción:

  • Llamadas a APIs externas — Timeouts de red, errores 503 por sobrecarga momentánea, límites de tasa (rate limiting) que se liberan en segundos
  • Conexiones a base de datosCannotGetJdbcConnectionException cuando el pool está temporalmente exhausto, lock timeouts en escrituras concurrentes
  • Mensajería — Fallos de publicación en Kafka o RabbitMQ cuando el broker está haciendo un rebalanceo de particiones
  • Llamadas entre microservicios — Errores de conexión cuando una instancia del servicio destino está reiniciándose

No uses @Retryable para:

  • Errores de validación de datos (IllegalArgumentException, ConstraintViolationException) — el reintento no cambiará el resultado
  • Errores de autenticación o autorización — reintentar solo genera ruido en los logs
  • Fallos de lógica de negocio predecibles — modela esos casos con excepciones específicas en noRetryFor

Configuración inicial: @EnableRetry {#configuracion}

En Spring Boot 4, añade @EnableRetry a tu clase principal o a cualquier @Configuration:

@SpringBootApplication
@EnableRetry
public class MiAplicacion {
    public static void main(String[] args) {
        SpringApplication.run(MiAplicacion.class, args);
    }
}

No añadas spring-retry al pom.xml — en Spring Boot 4 ya está incluido en el core. Si lo tienes como dependencia explícita de una migración anterior desde Boot 3, elimínalo:

<!-- ELIMINAR de pom.xml al migrar a Spring Boot 4 -->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

Uso básico de @Retryable {#uso-basico}

La forma más común: reintentar cuando lanza una excepción específica, con una pausa fija entre intentos.

@Service
public class PagosService {

    private final PasarelaExterna pasarelaExterna;

    @Retryable(
        retryFor = { TimeoutException.class, ConnectException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)   // 1 segundo entre reintentos
    )
    public ResultadoPago procesarPago(Pago pago) {
        // lanza TimeoutException si la pasarela no responde en 5s
        return pasarelaExterna.cobrar(pago);
    }

    @Recover
    public ResultadoPago recuperar(TimeoutException ex, Pago pago) {
        // Ejecutado cuando se agotan los 3 intentos con TimeoutException
        log.error("Pago {} fallido tras {} intentos: {}",
            pago.getId(), 3, ex.getMessage());
        return ResultadoPago.pendiente(pago.getId(), "reintento-manual");
    }

    @Recover
    public ResultadoPago recuperar(ConnectException ex, Pago pago) {
        // @Recover distinto por tipo de excepción
        alertas.notificarPasarelaNoDisponible(ex);
        return ResultadoPago.error(pago.getId(), "pasarela-no-disponible");
    }
}

Puntos clave sobre la configuración:

  • retryFor — Qué excepciones activan el reintento. Solo se reintenta con estas excepciones (y sus subclases). Si el método lanza otra excepción, no se reintenta.
  • maxAttempts — Número total de intentos, incluyendo el original. maxAttempts = 3 significa 1 intento original + 2 reintentos.
  • @Backoff(delay = 1000) — Espera 1 segundo entre cada reintento.
  • @Recover — Método de fallback que recibe la excepción como primer parámetro. Si hay varios @Recover, Spring selecciona el más específico al tipo de excepción lanzada.

Backoff exponencial {#backoff-exponencial}

El backoff fijo (esperar siempre el mismo tiempo) puede saturar un servicio ya sobrecargado. El backoff exponencial aumenta el tiempo de espera progresivamente, dando más tiempo al servicio destino para recuperarse:

@Retryable(
    retryFor = { ServiceUnavailableException.class },
    maxAttempts = 5,
    backoff = @Backoff(
        delay = 500,           // espera inicial: 500ms
        multiplier = 2.0,      // multiplica por 2 en cada reintento
        maxDelay = 16_000,     // máximo 16 segundos (no más)
        random = true          // añade variación aleatoria (jitter) para evitar thundering herd
    )
)
public RespuestaApi llamarServicioExterno(PeticionApi peticion) {
    return clienteHttp.post("/api/operacion", peticion, RespuestaApi.class);
}

Tiempos de espera con esta configuración (con random = true, los valores exactos varían ±20%):

IntentoEspera aproximada
1 (original)
2~500ms
3~1.000ms
4~2.000ms
5~4.000ms

El parámetro random = true añade jitter (variación aleatoria) al tiempo de espera. Es fundamental en sistemas con múltiples instancias del mismo servicio: si todas las instancias fallan al mismo tiempo y reintentan con el mismo intervalo exacto, el pico de carga resultante puede derribar el servicio destino de nuevo (efecto thundering herd). Con jitter, los reintentos se distribuyen en el tiempo.

Excluir excepciones del reintento {#noretry}

Usa noRetryFor para excepciones que nunca deben reintentarse, aunque sean subclases de las incluidas en retryFor:

@Retryable(
    retryFor = { RuntimeException.class },
    noRetryFor = {
        IllegalArgumentException.class,
        EntityNotFoundException.class,
        AccessDeniedException.class    // errores de autorización: nunca reintentar
    },
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000)
)
public Factura generarFactura(Long pedidoId, String formatoSalida) {
    validarFormato(formatoSalida);          // lanza IllegalArgumentException → NO reintenta
    Pedido pedido = pedidoRepo.findById(pedidoId)  // lanza EntityNotFoundException → NO reintenta
        .orElseThrow(() -> new EntityNotFoundException("Pedido " + pedidoId));
    return generador.generar(pedido, formatoSalida); // lanza IOException → SÍ reintenta
}

El método @Recover: el fallback definitivo {#recover}

El método @Recover se ejecuta cuando se agotan todos los intentos de @Retryable. Es el último recurso antes de que el error llegue al llamador. Reglas estrictas de la firma:

  1. Mismo tipo de retorno que el método @Retryable (o supertipo compatible)
  2. Primer parámetro: la excepción que agotó los reintentos
  3. Resto de parámetros: los mismos argumentos del método @Retryable, en el mismo orden
  4. Misma clase o subclase de excepción en la firma si tienes múltiples @Recover
@Service
public class NotificacionesService {

    @Retryable(
        retryFor = { SmtpException.class, SocketTimeoutException.class },
        maxAttempts = 4,
        backoff = @Backoff(delay = 2000, multiplier = 1.5)
    )
    public void enviarEmail(String destinatario, String asunto, String cuerpo) {
        smtpClient.enviar(destinatario, asunto, cuerpo);
    }

    // Fallback específico para SmtpException
    @Recover
    public void recuperarSmtp(SmtpException ex, String destinatario, String asunto, String cuerpo) {
        colaReenvio.guardar(new EmailPendiente(destinatario, asunto, cuerpo));
        log.warn("Email a {} encolado para reenvío manual. Error SMTP: {}", destinatario, ex.getCode());
    }

    // Fallback específico para SocketTimeoutException
    @Recover
    public void recuperarTimeout(SocketTimeoutException ex, String destinatario, String asunto, String cuerpo) {
        // El timeout puede indicar un problema de red transitorio más largo — alerta
        alertas.notificarFalloSmtpCritico(destinatario, ex);
        throw new RuntimeException("SMTP no disponible tras 4 intentos", ex);
    }
}

Truco importante: si el método @Recover lanza una excepción, esa excepción es la que recibe el llamador original. Si quieres que el error se propague aunque haya un @Recover, lanza desde el método recover.

Configuración desde application.properties {#application-properties}

Puedes externalizar los parámetros de retry a application.properties para cambiarlos sin recompilar:

@Retryable(
    retryFor = { Exception.class },
    maxAttemptsExpression = "${retry.max-attempts:3}",
    backoff = @Backoff(
        delayExpression = "${retry.delay:1000}",
        multiplierExpression = "${retry.multiplier:2.0}",
        maxDelayExpression = "${retry.max-delay:10000}"
    )
)
public Resultado operacionCritica(Parametros params) {
    return servicioExterno.ejecutar(params);
}
# application.properties
retry.max-attempts=5
retry.delay=500
retry.multiplier=2.0
retry.max-delay=16000

Esto es especialmente útil en entornos donde los tiempos de respuesta de los servicios externos varían por entorno (desarrollo, staging, producción).

RetryTemplate: el enfoque programático {#retry-template}

Cuando necesitas lógica de retry más dinámica (la política cambia en tiempo de ejecución, o necesitas retry en código que no puede usar anotaciones AOP), usa RetryTemplate directamente:

@Configuration
public class RetryConfig {

    @Bean
    public RetryTemplate retryTemplate() {
        ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
        backOff.setInitialInterval(500);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(16_000);

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(
            5,  // maxAttempts
            Map.of(
                TimeoutException.class, true,
                ConnectException.class, true,
                IllegalArgumentException.class, false   // no reintentar
            ),
            true   // traverseCauses: busca en la cadena de causas también
        );

        return RetryTemplate.builder()
            .customPolicy(retryPolicy)
            .customBackoff(backOff)
            .retryOn(TimeoutException.class)
            .build();
    }
}

@Service
public class ServicioConRetryProgramatico {

    private final RetryTemplate retryTemplate;

    public Resultado ejecutarConRetry(String parametro) {
        return retryTemplate.execute(
            context -> {
                int intento = context.getRetryCount() + 1;
                log.debug("Intento {} de 5", intento);
                return servicioExterno.llamar(parametro);
            },
            context -> {
                // Recovery callback — equivalente a @Recover
                log.error("Agotados {} intentos", context.getRetryCount());
                return Resultado.fallback(parametro);
            }
        );
    }
}

RetryTemplate es también la forma de añadir retry a código que no puede ser un Spring bean (por ejemplo, lógica en constructores o en métodos estáticos).

Monitorización con Spring Actuator {#actuator}

Spring Boot Actuator expone métricas de retry automáticamente cuando @EnableRetry está activo. Activa el endpoint en application.properties:

# Exponer métricas de retry via Actuator
management.endpoints.web.exposure.include=health,metrics,retries
management.endpoint.retries.enabled=true

Las métricas disponibles en /actuator/metrics:

// GET /actuator/metrics/spring.retry.attempts
{
  "name": "spring.retry.attempts",
  "measurements": [
    { "statistic": "COUNT", "value": 47.0 }
  ],
  "availableTags": [
    { "tag": "class", "values": ["PagosService"] },
    { "tag": "method", "values": ["procesarPago"] },
    { "tag": "exception", "values": ["TimeoutException", "none"] },
    { "tag": "recovered", "values": ["true", "false"] }
  ]
}

Con estas métricas puedes construir alertas en Prometheus/Grafana: si spring.retry.attempts con recovered=false supera un umbral, significa que los reintentos no están resolviendo el fallo — puede indicar una degradación del servicio externo.

Testing de @Retryable {#testing}

@Retryable usa AOP, por lo que el proxy solo existe dentro del contexto Spring. No puedes testear @Retryable con tests unitarios puros — necesitas @SpringBootTest o una configuración de contexto reducida.

Test con @SpringBootTest

@SpringBootTest
class PagosServiceRetryTest {

    @Autowired
    private PagosService pagosService;

    @MockitoBean   // Spring Boot 4: @MockBean pasa a @MockitoBean
    private PasarelaExterna pasarelaExterna;

    @Test
    void debeReintentarTresVecesYTenerExito() {
        // Falla las 2 primeras llamadas, tiene éxito en la 3ª
        when(pasarelaExterna.cobrar(any()))
            .thenThrow(new TimeoutException("timeout 1"))
            .thenThrow(new TimeoutException("timeout 2"))
            .thenReturn(ResultadoPago.ok("TX-789"));

        ResultadoPago resultado = pagosService.procesarPago(new Pago("250€"));

        assertThat(resultado.getEstado()).isEqualTo(EstadoPago.OK);
        verify(pasarelaExterna, times(3)).cobrar(any());
    }

    @Test
    void debeUsarRecoveryAlAgotarIntentos() {
        // Falla siempre
        when(pasarelaExterna.cobrar(any()))
            .thenThrow(new TimeoutException("servicio no disponible"));

        ResultadoPago resultado = pagosService.procesarPago(new Pago("100€"));

        // El método @Recover devuelve ResultadoPago.pendiente(...)
        assertThat(resultado.getEstado()).isEqualTo(EstadoPago.PENDIENTE);
        verify(pasarelaExterna, times(3)).cobrar(any());  // maxAttempts = 3
    }

    @Test
    void noDebeReintentarConExcepcionExcluida() {
        when(pasarelaExterna.cobrar(any()))
            .thenThrow(new IllegalArgumentException("importe negativo"));

        // IllegalArgumentException está en noRetryFor — debe propagarse sin reintentos
        assertThatThrownBy(() -> pagosService.procesarPago(new Pago("-50€")))
            .isInstanceOf(IllegalArgumentException.class);
        verify(pasarelaExterna, times(1)).cobrar(any());  // solo 1 intento
    }
}

Test con contexto reducido (más rápido)

Si @SpringBootTest es demasiado lento, puedes cargar solo los beans necesarios:

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {PagosService.class, RetryConfiguration.class})
@EnableRetry
class PagosServiceRetryUnitTest {

    @Autowired
    private PagosService pagosService;

    @MockitoBean
    private PasarelaExterna pasarelaExterna;

    // ... mismos tests que arriba, pero el contexto carga en ~200ms vs ~5s de @SpringBootTest
}

Diferencias entre Spring Boot 3 (spring-retry externo) y Spring Boot 4 (nativo) {#diferencias}

AspectoSpring Boot 3Spring Boot 4
Dependencia en pom.xmlspring-retry obligatoriaNo necesaria
Atributo de excepciones incluidasvalue o includeretryFor
Atributo de excepciones excluidasexcludenoRetryFor
@MockBean en tests@MockBean@MockitoBean
RetryTemplateDisponible vía spring-retryDisponible en el core
Métricas ActuatorRequiere configuración extraIntegradas
@EnableRetryIgualIgual
@RecoverIgualIgual
@BackoffIgualIgual

Antipatrones a evitar {#antipatrones}

Reintentar en el mismo bean@Retryable usa AOP, lo que significa que la llamada debe pasar por el proxy Spring. Si llamas al método anotado con this.metodoRetryable() desde dentro del mismo bean, el proxy no intercepta la llamada y el retry no funciona. La solución: inyecta el propio bean o mueve el método @Retryable a un bean separado.

// ❌ No funciona — llamada interna sin pasar por el proxy
@Service
public class MiServicio {
    @Retryable(retryFor = Exception.class, maxAttempts = 3)
    public void metodoConRetry() { ... }

    public void otroMetodo() {
        this.metodoConRetry();  // ❌ el proxy no intercepta esto
    }
}

// ✅ Correcto — inyecta el bean para que la llamada pase por el proxy
@Service
public class MiServicio {
    @Autowired
    private MiServicio self;   // self-injection

    @Retryable(retryFor = Exception.class, maxAttempts = 3)
    public void metodoConRetry() { ... }

    public void otroMetodo() {
        self.metodoConRetry();  // ✅ pasa por el proxy AOP
    }
}

maxAttempts muy alto con delay muy corto — En un servicio con alta concurrencia, 50 instancias haciendo maxAttempts = 10 con delay = 100ms puede generar 500 peticiones adicionales en 1 segundo sobre un servicio ya saturado. Calibra siempre maxAttempts y delay según el SLA del servicio externo y el número esperado de llamadas concurrentes.


Relacionado: @Retryable integrado es una de las novedades de Spring 7 — consulta también la guía de migración Spring 6 a Spring 7 si estás en Spring Boot 3.

¿Estás diseñando una arquitectura resiliente con Spring Boot y necesitas evaluar cuándo usar @Retryable, circuit breaker o bulkhead? Hablemos.

¿Buscas un consultor IT para tu empresa? Conoce mis servicios de consultoría IT: arquitectura cloud, desarrollo fullstack y liderazgo técnico. ¿Empresa en Mallorca o Baleares? Consultor IT en Mallorca.

¿Listo para transformar tu stack tecnológico?

Hablemos sobre cómo llevar tus sistemas al siguiente nivel, optimizar el rendimiento y potenciar el talento de tu equipo.