¿Cómo manejas redes inestables — reintentos, backoff exponencial y circuit breakers en un cliente mobile?
Un bucle de reintentos sin jitter ni un techo no se recupera de una red inestable — convierte una mala conexión en una denegación de servicio autoinfligida.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo manejas redes inestables — reintentos, backoff exponencial y circuit breakers en un cliente mobile?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Reintentar es fácil; reintentar correctamente es la pregunta de entrevista
Todo cliente mobile eventualmente necesita reintentar una petición fallida — un traspaso de torre celular cortó la conexión, un balanceador de carga devolvió momentáneamente un 503, un timeout saltó porque el usuario entró a un ascensor. El instinto ingenuo es un bucle while que llama al mismo endpoint de nuevo inmediatamente. Ese instinto está mal de tres formas distintas a la vez: puede reintentar una petición que ya tuvo éxito del lado del servidor (duplicando un pago), puede golpear a un servidor que ya está sufriendo y empeorar la interrupción entre todos los clientes que hacen lo mismo en el mismo momento, y no tiene idea de cuándo rendirse. Una respuesta senior separa el problema en tres decisiones independientes: este error, ¿es reintentable en absoluto, cuánto espero entre intentos y cuándo dejo de intentar por completo para este endpoint.
#Decidir qué es reintentable
No todo fallo merece otro intento. Un timeout de conexión, un reset de socket o una respuesta 5xx generalmente reflejan una condición transitoria de servidor o red y vale la pena reintentarlos. Una respuesta 4xx — excepto 429, que significa explícitamente "te están limitando, intenta más tarde, posiblemente después de Retry-After" — casi siempre significa que la petición en sí está mal (auth incorrecta, cuerpo malformado, no encontrado), y reintentarla idéntica solo desperdicia presupuesto de reintentos en una petición que fallará siempre. La otra mitad de "esto es reintentable" es la idempotencia: reintentar un GET es gratis, pero reintentar un POST que crea un recurso arriesga crearlo dos veces si el primer intento en realidad tuvo éxito y solo se perdió la respuesta. La solución es una clave de idempotencia generada una vez por operación lógica y enviada en cada reintento, para que el servidor pueda reconocer "esto ya lo procesé" y devuelva el resultado original en vez de rehacer el trabajo.
#Backoff con jitter, no backoff solo
El backoff exponencial puro (esperar 1s, 2s, 4s, 8s...) resuelve el problema de "dejar de golpear inmediatamente" pero no el de "todos los clientes retroceden al mismo ritmo" — si mil dispositivos fallaron todos en el mismo momento por una interrupción compartida, todos reintentan también en el mismo momento, en oleadas sincronizadas que nunca dejan recuperarse al servidor. El jitter — aleatorizar el retraso real dentro del sobre exponencial — rompe esa sincronización. El "jitter completo" (elegir un retraso aleatorio entre 0 y el tope exponencial) suele bastar; variantes más sofisticadas rastrean el retraso anterior para evitar agruparse cerca de cero, pero el jitter completo por sí solo te da la mayor parte del beneficio con una fracción de la complejidad.
class RetryWithBackoffInterceptor(private val maxRetries: Int = 4,private val baseDelayMs: Long = 250,private val maxDelayMs: Long = 8_000,) : Interceptor {override fun intercept(chain: Interceptor.Chain): Response {var attempt = 0var lastException: IOException? = nullwhile (attempt <= maxRetries) {try {val response = chain.proceed(chain.request())if (response.isSuccessful || !isRetryable(response.code)) {return response}response.close()} catch (e: IOException) {if (!isRetryableException(e)) throw elastException = e}attempt++if (attempt > maxRetries) breakThread.sleep(backoffDelay(attempt))}throw lastException ?: IOException("Retries exhausted")}private fun isRetryable(code: Int) = code == 429 || code in 500..599private fun isRetryableException(e: IOException) =e is SocketTimeoutException || e is ConnectException// Jitter completo: un retraso aleatorio entre 0 y el tope exponencial.private fun backoffDelay(attempt: Int): Long {val exp = (baseDelayMs * 2.0.pow(attempt - 1)).toLong().coerceAtMost(maxDelayMs)return Random.nextLong(0, exp + 1)}}
Nota el techo en dos lugares: maxRetries limita cuántas veces intentamos en total, y maxDelayMs limita cuánto puede crecer una sola espera, así que el backoff converge en vez de eventualmente esperar minutos entre intentos en una petición que el usuario todavía está mirando con un spinner en pantalla.
#Circuit breakers: detenerte antes de siquiera intentar
Los reintentos ayudan a que una petición sobreviva un bache. No ayudan cuando una dependencia downstream completa está caída por un período extendido — en ese caso, cada petición desde cada pantalla de la app sigue pagando el costo completo de reintentar y esperar timeout contra algo que no tiene ninguna posibilidad de tener éxito, quemando batería, tiempo de radio y capacidad de respuesta percibida para nada. Un circuit breaker rastrea la tasa de fallos de una dependencia dada y, una vez que cruza un umbral, salta a un estado abierto donde las llamadas fallan de inmediato sin siquiera tocar la red. Después de una ventana de enfriamiento pasa a semiabierto y deja pasar una única petición de prueba; el éxito vuelve a cerrar el circuito, el fallo lo vuelve a abrir.
class CircuitBreaker(private val failureThreshold: Int = 5,private val resetTimeoutMs: Long = 30_000,) {private enum class State { CLOSED, OPEN, HALF_OPEN }private var state = State.CLOSEDprivate var consecutiveFailures = 0private var openedAt = 0L@Synchronizedfun <T> execute(call: () -> T): T {if (state == State.OPEN) {if (System.currentTimeMillis() - openedAt < resetTimeoutMs) {throw CircuitOpenException()}state = State.HALF_OPEN}return try {val result = call()onSuccess()result} catch (e: Exception) {onFailure()throw e}}@Synchronizedprivate fun onSuccess() {consecutiveFailures = 0state = State.CLOSED}@Synchronizedprivate fun onFailure() {consecutiveFailures++if (state == State.HALF_OPEN || consecutiveFailures >= failureThreshold) {state = State.OPENopenedAt = System.currentTimeMillis()}}}class CircuitOpenException : Exception("Circuit breaker is open — failing fast")
En mobile, el breaker usualmente se delimita por host o por feature en vez de globalmente — no quieres que un endpoint de analytics con problemas dispare un breaker que también protege el checkout. Combínalo con conciencia de conectividad (ConnectivityManager.NetworkCallback en Android, NWPathMonitor en iOS) para que el cliente ni siquiera intente una llamada — reintentable o no — mientras el dispositivo reporta que no hay red en absoluto, y en cambio encole la petición hasta que vuelva la conectividad.
#Uniéndolo todo en la capa de UI
Nada de esto es invisible para el usuario por accidente — una implementación senior expone estado, no solo datos: una pantalla vinculada a un circuito abierto debería mostrar "problemas para conectar" en vez de un spinner infinito, y una escritura offline encolada debería mostrarse como pendiente en vez de desaparecer silenciosamente. La pila de reintentos/backoff/circuit-breaker es lo que mantiene ese estado honesto en vez de que la UI mienta sobre si una petición está en curso, reintentando o ya se rindió.