wandres.dev
REDES · Retrofit, Ktor y OkHttp

OkHttp por debajo: interceptores, tiempos y conexiones

Debajo de cualquier interfaz anotada hay un cliente que hace el trabajo real, y casi todo lo que se percibe como comportamiento de red se decide en esa capa. Esta lección estudia OkHttp desde dentro: por qué el cliente debe ser único y compartido, en qué se diferencian los interceptores de aplicación de los de red, cómo se implementa autenticación reactiva sin provocar bucles infinitos, qué reintenta el cliente por su cuenta y qué no, y cómo los cuatro tiempos de espera y el pool de conexiones determinan la latencia que el usuario percibe.

⏱ 22 min

Casi todas las decisiones que un equipo cree estar tomando sobre su capa de red se toman en realidad una planta más abajo, en OkHttp, y muchas veces sin que nadie las tome explícitamente. Cuánto se espera antes de rendirse, si se reintenta y bajo qué condiciones, cuántas conexiones se mantienen vivas, si dos peticiones simultáneas al mismo dominio comparten socket o compiten por él, qué cabecera lleva el testigo de sesión y qué pasa cuando caduca: nada de eso está en la interfaz anotada. Está en el cliente que se construye una vez, se pasa como dependencia y a menudo se configura copiando un fragmento de una respuesta de foro. Merece la pena bajar a esa planta con calma, porque la diferencia entre una aplicación que se siente ágil con mala cobertura y otra que se queda colgada en blanco no suele estar en el diseño de la API, sino en cuatro números y en el orden de una lista de interceptores.

🎯 Al terminar esta lección sabrás
  • Justificar por qué el cliente debe ser una instancia única compartida y cómo derivar variantes sin duplicar sus recursos.
  • Distinguir un interceptor de aplicación de uno de red por lo que cada uno observa y por cuántas veces se ejecuta.
  • Implementar autenticación reactiva con el mecanismo previsto, evitando bucles y peticiones simultáneas de renovación.
  • Configurar los cuatro tiempos de espera y entender qué reintenta el cliente por su cuenta y qué debe reintentarse arriba.

Un cliente único, y variantes derivadas de él

La instancia del cliente no es un objeto ligero que se crea por conveniencia allí donde hace falta. Contiene el repartidor con su pool de hilos, el pool de conexiones con los sockets vivos, la caché de respuestas si la hay y el estado de negociación de TLS por servidor. Crear una segunda instancia significa duplicar todo eso y, sobre todo, tirar a la basura el trabajo de establecer conexiones que la primera ya tenía calientes: cada nueva instancia empieza pagando resolución de nombre, saludo TCP y negociación TLS otra vez.

Cuando de verdad hacen falta configuraciones distintas —por ejemplo, un tiempo de espera generoso para subir archivos y otro estricto para consultas interactivas— la forma correcta es derivar del cliente existente, porque la variante comparte el repartidor, el pool y la caché del original y solo cambia lo que se le indique.

val clienteBase = OkHttpClient.Builder()
    .connectTimeout(10.seconds.toJavaDuration())
    .readTimeout(15.seconds.toJavaDuration())
    .callTimeout(30.seconds.toJavaDuration())
    .addInterceptor(CabecerasComunes())
    .build()

// Comparte pool, repartidor y cache; solo cambia el limite temporal.
val clienteSubidas = clienteBase.newBuilder()
    .callTimeout(5.minutes.toJavaDuration())
    .build()
⚠️
El síntoma de las instancias duplicadas

Si tu aplicación tarda visiblemente más en la primera petición de cada pantalla que en las siguientes de la misma pantalla, sospecha de un cliente construido dentro de una fábrica que se invoca por cada dependencia. Cuando cada pantalla trae su propio cliente, ninguna aprovecha las conexiones abiertas por las demás y el usuario paga un saludo TLS completo a cada navegación. El mismo síntoma aparece con clientes creados dentro de funciones componibles o de constructores de modelos de vista.

Dos anillos de interceptores que ven cosas distintas

Un interceptor es una función que recibe una petición, decide qué hacer con ella y devuelve una respuesta. Puede modificar la petición antes de dejarla continuar, modificar la respuesta que vuelve, cortocircuitar la cadena devolviendo una respuesta fabricada o repetir la llamada. Lo importante es que existen dos anillos distintos y que elegir mal el anillo produce comportamientos difíciles de diagnosticar.

Los interceptores de aplicación se ejecutan una sola vez por llamada lógica, ven la petición tal como la escribió el código de arriba y no se enteran de nada de lo que ocurra por debajo: si hubo una redirección, si hubo un reintento sobre otra ruta, si la respuesta salió de la caché. Los interceptores de red se ejecutan una vez por petición física, ven las cabeceras reales que viajan por el cable, observan el objeto de conexión y participan de cada redirección y de cada reintento por separado.

🧭

De aplicación

Ideal para cabeceras propias del dominio, para etiquetar la llamada con su origen y para envolver el resultado en métricas de negocio. No observa redirecciones ni caché.

🔌

De red

Ideal para diagnóstico fino, para inspeccionar compresión y cabeceras generadas por la propia biblioteca, y para saber sobre qué conexión y con qué protocolo viajó cada intento.

🧾

Registro

El interceptor de registro colocado como de aplicación muestra lo que tú escribiste, y colocado como de red muestra lo que realmente se envió. Ver ambas salidas a la vez es el mejor ejercicio de diagnóstico posible.

⛓️

Orden

La lista se recorre en el orden en que se añadió para la petición y en orden inverso para la respuesta. Un interceptor que firma la petición debe ir después de los que la modifican.

class CabecerasComunes : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()
        val conCabeceras = original.newBuilder()
            .header("Accept", "application/json")
            .header("X-Cliente-Version", BuildConfig.VERSION_NAME)
            .build()
        return chain.proceed(conCabeceras)
    }
}

Hay dos reglas de higiene que el propio contrato de la cadena impone y cuya violación produce fallos desconcertantes. La primera es que hay que llamar a la continuación exactamente una vez por camino de ejecución: no llamarla nunca deja la llamada colgada, llamarla dos veces sin cerrar la primera respuesta filtra una conexión. La segunda es que el cuerpo de una respuesta se consume una sola vez, así que un interceptor que quiera leerlo para registrarlo debe usar el mecanismo que produce una copia acotada en lugar de vaciarlo.

Autenticación reactiva, y lo que el cliente ya reintenta solo

Añadir el testigo de sesión con un interceptor es la mitad del problema; la otra mitad es qué hacer cuando ese testigo caduca. La solución ingenua consiste en detectar un 401 dentro del propio interceptor y repetir la llamada, y funciona hasta el día en que el servidor rechaza también el testigo renovado y la aplicación entra en un bucle que consume batería y datos hasta que alguien la mata.

OkHttp prevé exactamente este caso con un mecanismo separado que se invoca solo cuando llega un 401, que recibe la respuesta rechazada y que puede devolver una petición nueva o bien nulo para rendirse. Devolver nulo es la única salida del bucle, y por eso el contador de intentos previos no es un adorno defensivo sino parte del diseño.

class AutenticadorDeSesion(private val sesion: Sesion) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.contadorDePrevias() >= 2) return null

        val nuevo = synchronized(this) {
            val actual = sesion.testigoActual()
            val usado = response.request.header("Authorization")
            // Si otro hilo ya renovo, reutiliza en lugar de renovar otra vez.
            if ("Bearer $actual" != usado) actual else sesion.renovarBloqueante()
        } ?: return null

        return response.request.newBuilder()
            .header("Authorization", "Bearer $nuevo")
            .build()
    }
}

private fun Response.contadorDePrevias(): Int =
    generateSequence(this) { it.priorResponse }.count()

El bloque sincronizado no es paranoia: cuando cinco peticiones simultáneas reciben un 401 a la vez, sin coordinación se disparan cinco renovaciones concurrentes, cuatro de las cuales invalidarán el testigo que acaba de obtener la quinta si el servidor rota credenciales al renovar. Ese fallo se manifiesta como cierres de sesión aleatorios que solo ocurren en producción y que nadie logra reproducir.

En cuanto a los reintentos, conviene tener claro el reparto de responsabilidades porque se duplica constantemente. El cliente reintenta por su cuenta los fallos de conexión: si un servidor tiene varias direcciones y una no responde, prueba con la siguiente; si una conexión reutilizada del pool resultó estar muerta, abre otra y repite. Lo que no hace, y hace bien en no hacerlo, es reintentar respuestas de error del servidor: un 500 es una respuesta legítima y decidir si repetirla exige saber si la operación era idempotente, algo que solo conoce quien la escribió.

💡
Reintentar arriba, con espera creciente y con techo

Cuando decidas reintentar un 500 o un 503, hazlo en la capa que conoce la semántica de la operación, nunca dentro de un interceptor genérico. Usa espera exponencial con un componente aleatorio para no sincronizar a toda tu base de usuarios contra un servidor que acaba de reiniciarse, limita el número de intentos, respeta la cabecera de reintento si el servidor la envía y no reintentes jamás una operación no idempotente sin una clave de idempotencia que el servidor entienda.

Cuatro relojes y un pool de conexiones

Los tiempos de espera son cuatro y miden cosas distintas, así que fijar solo uno deja huecos por los que se cuelan bloqueos largos. El de conexión limita lo que tarda en establecerse el socket, incluido el saludo TLS. El de lectura limita el silencio máximo entre dos bloques de datos recibidos, no la duración total. El de escritura hace lo propio al enviar. Y el de llamada, que suele olvidarse, acota la operación completa de extremo a extremo, incluidas redirecciones, reintentos internos y la lectura íntegra del cuerpo.

La distinción entre el reloj de lectura y el de llamada es la que más sorpresas produce: una descarga lenta pero constante nunca dispara el de lectura porque siempre llegan bytes, y puede prolongarse indefinidamente si nadie puso un límite total. Por eso el de llamada es el único que garantiza que la interfaz no se queda esperando para siempre.

flowchart LR
A[Peticion] --> B[Interceptores de aplicacion]
B --> C[Redirecciones y reintentos internos]
C --> D[Cache]
D --> E[Conexion desde el pool o nueva]
E --> F[Interceptores de red]
F --> G[Servidor]
G --> F
F --> E
E --> B
B --> H[Respuesta tipada]
style B fill:#89b4fa,color:#11111b
style F fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b

El pool de conexiones mantiene abiertos unos pocos sockets inactivos durante unos minutos para que la siguiente petición al mismo destino se ahorre el establecimiento completo. En un móvil ese ahorro es enorme, porque sobre una red celular el saludo TCP más la negociación TLS pueden costar más que la transferencia útil. Con HTTP/2 el efecto se multiplica: una sola conexión multiplexa muchas peticiones simultáneas al mismo servidor, de modo que la ráfaga de llamadas que dispara una pantalla al abrirse viaja por un único socket ya establecido en lugar de abrir seis.

De ahí se sigue el consejo práctico que cierra la lección y que contradice la intuición: cuando una pantalla va lenta, antes de tocar los tiempos de espera conviene comprobar cuántas conexiones se están abriendo de verdad. Un interceptor de red que registre el protocolo negociado y la identidad de la conexión de cada intento responde en cinco minutos a una pregunta que muchos equipos contestan durante meses a base de suposiciones.

La red no es una función lenta, es un sistema con estado que atraviesa tu arquitectura

Existe una abstracción muy cómoda y muy extendida según la cual una llamada de red es simplemente una función que tarda: se le pasan argumentos, se espera un rato y devuelve un valor o falla. Esa abstracción es la que permite escribir un repositorio legible y no habría que renunciar a ella en el código de arriba, pero sostenerla también como modelo mental de lo que ocurre por debajo es la causa de una familia entera de problemas que después se atribuyen a la mala cobertura o al servidor. Una llamada no es una función: es un episodio dentro de un sistema con memoria. Antes de que exista la petición ya hay o no hay una conexión abierta a ese servidor, y esa diferencia vale cientos de milisegundos que ninguna optimización del código puede recuperar. Mientras la petición está en vuelo comparte un socket multiplexado con otras cuatro que alguien lanzó en paralelo, de modo que su latencia depende de decisiones tomadas en otra pantalla. Cuando falla, el fallo puede haberse producido en cualquiera de siete capas que la excepción no distingue. Y cuando termina, deja el sistema en un estado distinto del que encontró: una conexión caliente, una entrada de caché, un testigo renovado, un contador de reintentos. Ver la capa de red como estado compartido y no como una sucesión de llamadas independientes es lo que hace evidentes de golpe las decisiones que de otro modo parecen arbitrarias. Por qué el cliente debe ser único: porque el estado que acumula es precisamente el valor. Por qué el orden de los interceptores importa: porque cada uno observa un momento distinto de la vida del episodio y no una etapa intercambiable de una tubería. Por qué el mecanismo de autenticación es reactivo y no preventivo: porque la caducidad la decide el otro extremo y no hay forma de saberla desde aquí. Y por qué el reintento no puede vivir en una utilidad genérica: porque repetir una operación solo es seguro si se conoce el efecto que tuvo la primera vez, y ese conocimiento no está en la capa de transporte sino en el dominio. El día en que la red deja de parecer una función lenta y empieza a parecer lo que es, la mitad de las decisiones de configuración dejan de ser cuestión de gusto y pasan a tener una respuesta correcta.

⚔️ Baja una planta y mira lo que pasa
  1. Registra un interceptor de aplicación y otro de red que impriman las cabeceras y compara ambas salidas en una llamada que atraviese una redirección.
  2. Provoca una caducidad de sesión forzando un 401 y verifica con registro que el mecanismo de autenticación se invoca una sola vez por llamada y que el contador corta el bucle.
  3. Lanza cinco peticiones simultáneas con el testigo ya caducado y comprueba cuántas renovaciones se disparan con y sin la sección sincronizada.
  4. Configura un tiempo de lectura de dos segundos y descarga un recurso grande y lento pero continuo; explica por qué no salta y qué reloj hay que fijar para acotarlo.
  5. Instrumenta la identidad de la conexión y el protocolo negociado, abre una pantalla que lance seis llamadas a la vez y cuenta cuántos sockets se abrieron realmente.