Reintentos con Failsafe
No toda llamada fallida a Black Book significa que Black Book está caída — construir una política de reintentos que sepa distinguirlo es lo que evita que un tropiezo se convierta en una respuesta equivocada.
Los tres clientes de proveedor de la lección anterior son correctos, pero también son ingenuos: la primera vez que una lectura de socket hacia Carfax expira, o VIS devuelve un 503 porque está momentáneamente sobrecargado, `fetchValuation` simplemente lanza una excepción y toda la solicitud de valuación falla. La mayoría de las fallas que producen estos proveedores son transitorias — un paquete perdido, un balanceador de carga enrutando brevemente hacia una instancia no saludable, un limitador de tasa que se reinicia en un segundo — y darse por vencido ante la primera señal de problema descarta llamadas que habrían tenido éxito si simplemente se hubieran reintentado. Ese es el problema que resuelve una política de reintentos, y Failsafe es la librería a la que recurre este curso para construir una.
Failsafe (la librería `dev.failsafe`, sucesora moderna de las antiguas coordenadas `net.jodah:failsafe`) es un kit de herramientas de resiliencia liviano y sin dependencias para Java y Kotlin: reintentos, circuit breakers, limitadores de tasa, bulkheads, fallbacks y timeouts, todos expresados como pequeños objetos `Policy` componibles con una API de builder fluida. Ocupa un territorio similar al de Resilience4j — ambas existen para envolver llamadas poco confiables en comportamiento de resiliencia configurable — pero la superficie de la API de Failsafe es más pequeña y sus políticas se componen con una sola llamada a `Failsafe.with(...)`, lo cual es gran parte de por qué se lee de forma limpia en cuanto tienes más de una política apiladas juntas, como pasará en este módulo a partir de la próxima lección.
Una `RetryPolicy` se construye una sola vez, de antemano, como un objeto inmutable y reutilizable — la misma instancia se comparte entre cada llamada a cada VIN, porque una política es solo configuración, no estado por llamada. `RetryPolicy.builder<ValuationResult>()` te permite fijar `withMaxAttempts(3)` para limitar cuántos intentos recibe una llamada en total, y `withBackoff(Duration.ofMillis(200), Duration.ofSeconds(2))` para esperar más entre cada intento en lugar de reintentar de inmediato. `withJitter(...)` agrega entonces un pequeño desplazamiento aleatorio a cada retardo de backoff — sin eso, si Black Book tiene un tropiezo que hace fallar a todas las solicitudes en vuelo a la vez, cada uno de esos llamadores reintentaría exactamente en los mismos intervalos, llegando de vuelta a Black Book en oleadas sincronizadas en lugar de un goteo distribuido.
Decidir qué cuenta como reintentable es la parte que realmente exige criterio. `handle(SocketTimeoutException::class.java)` o un predicado personalizado `handleIf { failure -> ... }` te permiten decir con precisión qué fallas merecen otro intento: un `SocketTimeoutException`, un reset de conexión, o un HTTP 503/429 sugieren todos una condición transitoria del otro lado. Un 400 o un 401, en cambio, significa que la solicitud misma estaba mal — un VIN mal formado, una API key expirada — y reintentarla tres veces más con backoff producirá exactamente el mismo rechazo tres veces más, al costo de tres viajes de ida y vuelta adicionales y, en algunos proveedores, tres golpes adicionales contra un límite de tasa que un error del cliente no debería estar consumiendo en primer lugar.
Es tentador tratar más reintentos como estrictamente más seguro, pero el modo de falla opuesto es real y tiene nombre: una tormenta de reintentos (retry storm). Si Black Book realmente está batallando — no caída, solo lenta y descartando carga — cada solicitud fallida que reintenta dos o tres veces multiplica el tráfico que la golpea precisamente en el momento en que menos puede absorber más carga, lo cual puede convertir una degradación parcial en una caída total. Los reintentos son una herramienta para absorber tropiezos breves e independientes, no para sostener a un proveedor que está fallando bajo carga sostenida; pasado cierto punto, más intentos solo agregan más carga a algo que ya batalla para mantenerse al día.
La válvula de seguridad para ese modo de falla es un presupuesto de reintentos: un tope sobre cuántos reintentos puede gastar todo el servicio contra un proveedor dado en una ventana de tiempo, independiente de cuántas llamadas individuales estén pidiendo uno. El `RetryPolicy` de Failsafe limita los intentos por llamada, pero un presupuesto limita el agregado — registra los intentos de reintento como una métrica de Datadog por proveedor, y trata un pico en esa métrica como una señal para replegarse (o deja que un circuit breaker, que es exactamente el tema de la próxima lección, detenga por completo el tráfico) en lugar de dejar que cada llamador siga reintentando de forma independiente para siempre.
Los reintentos te compran resiliencia contra tropiezos, pero no hacen nada por un proveedor que realmente está caído durante un tramo prolongado — en ese caso, cada reintento simplemente retrasa la falla inevitable por lo que dure el calendario de backoff, desperdiciando tiempo en cada solicitud que lo golpea. La próxima lección agrega un circuit breaker encima de esta política de reintentos, de modo que, una vez que las fallas de un proveedor cruzan un umbral, el servicio deja de intentarlo por completo — rápido, y a propósito — hasta que haya evidencia real de que se ha recuperado.
import dev.failsafe.RetryPolicyimport java.io.IOExceptionimport java.net.SocketTimeoutExceptionimport java.time.Duration// A policy is stateless configuration, built once and reused across every// call to every provider -- it is not tied to a single request.val networkRetryPolicy: RetryPolicy<ValuationResult> = RetryPolicy.builder<ValuationResult>().handle(SocketTimeoutException::class.java, IOException::class.java).withBackoff(Duration.ofMillis(200), Duration.ofSeconds(2)).withJitter(Duration.ofMillis(100)).withMaxAttempts(3).onRetry { event ->log.warn("Retrying valuation call, attempt {}", event.attemptCount)}.build()
Builds a reusable RetryPolicy with exponential backoff and jitter, retrying only network-level exceptions; it can't run in-browser because dev.failsafe and the exception types it handles are real JVM classes with no in-browser equivalent.
import dev.failsafe.RetryPolicyimport java.time.Durationclass RetryableProviderException(provider: String, code: Int) :RuntimeException("$provider returned retryable status $code")class NonRetryableProviderException(provider: String, code: Int) :RuntimeException("$provider returned non-retryable status $code")// A 429 or a 503 usually means "try again shortly, I am just overloaded."// A 400 means the VIN or request itself was invalid -- retrying it three// times with backoff just burns three round trips for the same guaranteed// failure, and on some providers counts against the rate limit besides.val providerRetryPolicy: RetryPolicy<ValuationResult> = RetryPolicy.builder<ValuationResult>().handleIf { failure -> failure is RetryableProviderException }.withMaxAttempts(4).withBackoff(Duration.ofMillis(150), Duration.ofSeconds(3)).withJitter(0.25).build()
Defines a retryable-versus-non-retryable exception split and a policy built with handleIf, so a 429 or 503 gets retried but a 400 never does; it can't run in-browser because it depends on the real dev.failsafe classes and is meant to compile against the actual provider client code.
import dev.failsafe.Failsafeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport okhttp3.OkHttpClientimport okhttp3.Requestclass BlackBookClient(private val client: OkHttpClient,private val baseUrl: String,private val retryPolicy: RetryPolicy<ValuationResult>) : ValuationProviderClient {override val providerName = "black-book"override suspend fun fetchValuation(vin: String): ValuationResult =withContext(Dispatchers.IO) {// Failsafe wraps a plain blocking lambda -- no coroutines// involved -- which is exactly right here, since we are already// parked on an IO dispatcher thread for the duration of the call.Failsafe.with(retryPolicy).get {val request = Request.Builder().url("$baseUrl/v2/valuations/$vin").get().build()client.newCall(request).execute().use { response ->when {response.code in 500..599 || response.code == 429 ->throw RetryableProviderException(providerName, response.code)!response.isSuccessful ->throw NonRetryableProviderException(providerName, response.code)else -> parseBlackBook(vin, response)}}}}}
Wires the retry policy into BlackBookClient by classifying each response into a retryable or non-retryable exception before Failsafe decides whether to retry; it can't run in-browser because it makes a real OkHttp network call guarded by a real Failsafe executor.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. providerRetryPolicy is built with handleIf { failure -> failure is RetryableProviderException }, and BlackBookClient throws RetryableProviderException for 5xx/429 responses but NonRetryableProviderException for 4xx responses. Why not just retry on every non-2xx response?